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
@@ -823,11 +823,18 @@
|
||||
const formData = new FormData(this);
|
||||
if (_isAudioSubmitC) {
|
||||
(_cSlidesData['ct1'] || []).forEach(f => formData.append('slides[]', f));
|
||||
for (const [tid, files] of Object.entries(_cSlidesData)) {
|
||||
if (tid === 'ct1') continue;
|
||||
const n = parseInt(tid.replace('ce', ''));
|
||||
files.forEach(f => formData.append('extra_track_slides_' + n + '[]', f));
|
||||
}
|
||||
// Walk extra-track sections in DOM order so slide index matches
|
||||
// the positional `extra_track_files[]` order the backend iterates.
|
||||
var extraEls = document.querySelectorAll('#ltac-extra .ltac-item');
|
||||
extraEls.forEach(function (el, domIdx) {
|
||||
var m = el.id.match(/^ltac-(e\d+)$/);
|
||||
if (!m) return;
|
||||
var tid = 'c' + m[1];
|
||||
var files = _cSlidesData[tid] || [];
|
||||
files.forEach(function (f) {
|
||||
formData.append('extra_track_slides[' + domIdx + '][]', f);
|
||||
});
|
||||
});
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 0; // no timeout — unlimited file size
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
|
||||
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
|
||||
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||||
$slideUrls = $video->slides->count() > 1
|
||||
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
|
||||
: [];
|
||||
|
||||
// Per-track slide URL map. Key "0" = primary, other keys = audio_track_id.
|
||||
// Slide sharing rule: if a track has no slides, fall back to primary, then to any
|
||||
// other track that has them (Video::slidesForTrack handles the resolution).
|
||||
$slideMap = ['0' => $video->slidesForTrack(null)
|
||||
->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()];
|
||||
foreach ($video->audioTracks as $_t) {
|
||||
$slideMap[(string) $_t->id] = $video->slidesForTrack($_t->id)
|
||||
->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all();
|
||||
}
|
||||
// Initial set (primary) — used for first paint before JS runs.
|
||||
$slideUrls = $slideMap['0'] ?? [];
|
||||
// Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language)
|
||||
$primaryLang = $video->language ?? 'default';
|
||||
$allLangData = \App\Data\Languages::all();
|
||||
@@ -32,6 +41,16 @@
|
||||
'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp,
|
||||
]));
|
||||
$hasMultipleTracks = $allAudioTracks->count() > 1;
|
||||
|
||||
// Synced lyrics, embedded inline (no separate request). Keyed by track id; "0" = primary.
|
||||
// Local mirror only — must not block page render on NAS I/O.
|
||||
$lyricsSvc = app(\App\Services\NasSyncService::class);
|
||||
$inlineLyrics = ['0' => $lyricsSvc->getLocalLyrics($video, null)];
|
||||
foreach ($video->audioTracks as $lt) {
|
||||
$inlineLyrics[(string) $lt->id] = $lyricsSvc->getLocalLyrics($video, $lt);
|
||||
}
|
||||
$lyricsOwner = \Illuminate\Support\Facades\Auth::id() === $video->user_id;
|
||||
$lyricsAllowed = \App\Models\Setting::get('lyrics_enabled', 'true') === 'true';
|
||||
@endphp
|
||||
|
||||
<div class="ytp-wrap" id="ytpWrap">
|
||||
@@ -47,6 +66,27 @@
|
||||
{{-- Bars canvas overlay --}}
|
||||
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
|
||||
|
||||
@if($lyricsAllowed)
|
||||
{{-- Synced lyrics overlay — one line at a time, anchored to the bottom --}}
|
||||
<div class="ytp-lyrics-overlay" id="ytpLyricsOverlay" style="display:none">
|
||||
<div class="ytp-lyrics-panel" id="ytpLyricsPanel">
|
||||
<div class="ytp-lyrics-cur" id="ytpLyrCur"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Live lyrics-generation progress (owner) --}}
|
||||
<div class="ytp-lyrics-gen" id="ytpLyrGen" style="display:none">
|
||||
<div class="ytp-lyrics-gen-inner">
|
||||
<div class="ytp-lyrics-gen-row">
|
||||
<span class="ytp-lyrics-gen-spark">🎤</span>
|
||||
<span class="ytp-lyrics-gen-label" id="ytpLyrGenLabel">Generating lyrics…</span>
|
||||
<span class="ytp-lyrics-gen-pct" id="ytpLyrGenPct">0%</span>
|
||||
</div>
|
||||
<div class="ytp-lyrics-gen-track"><div class="ytp-lyrics-gen-bar" id="ytpLyrGenBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Gradient --}}
|
||||
<div class="ytp-gradient-bottom"></div>
|
||||
|
||||
@@ -146,6 +186,31 @@
|
||||
<span class="ytp-settings-val" id="ytpSpeedLabel">Normal</span>
|
||||
<svg class="ytp-chevron" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||
</div>
|
||||
{{-- 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>
|
||||
@if($lyricsOwner && $lyricsAllowed)
|
||||
{{-- Owner-only: generate/regenerate and edit lyrics live inside the gear so
|
||||
they're always reachable on mobile and don't crowd the control bar. --}}
|
||||
<div class="ytp-settings-item" id="ytpGenLyricsBtn" style="display:none"
|
||||
data-gen-url="{{ route('videos.lyrics.generate', $video) }}">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 6h9v2H4V6zm0 4h9v2H4v-2zm0 4h6v2H4v-2zm13-9 1.2 2.8L21 9l-2.8 1.2L17 13l-1.2-2.8L13 9l2.8-1.2L17 5z"/></svg>
|
||||
<span class="genlyr-label">Generate lyrics</span>
|
||||
</div>
|
||||
<div class="ytp-settings-item" id="ytpEditLyricsBtn" style="display:none"
|
||||
onclick="if(window.openLyricsEditor){window.openLyricsEditor();var p=document.getElementById('ytpSettingsPanel');if(p)p.classList.remove('open');}">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.51-2.51a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 2-2.16z"/></svg>
|
||||
<span>Edit lyrics</span>
|
||||
</div>
|
||||
<div class="ytp-settings-item" id="ytpDeleteLyricsBtn" style="display:none"
|
||||
data-del-url="{{ route('videos.lyrics.delete', $video) }}">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<span>Delete lyrics</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="ytp-speed-panel" id="ytpSpeedPanel">
|
||||
<div class="ytp-speed-back" id="ytpSpeedBack">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
@@ -166,6 +231,15 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
|
||||
</button>
|
||||
|
||||
@if($lyricsAllowed)
|
||||
{{-- Lyrics toggle (hidden until lyrics are available) --}}
|
||||
<button class="ytp-button ytp-lyrics-btn" id="ytpLyricsBtn" title="Lyrics" style="display:none">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 6h11v2H4V6zm0 5h11v2H4v-2zm0 5h7v2H4v-2zm15.5-6.5 1.5 1.5-6 6L12 18l1-3 5.5-5.5z"/></svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Owner lyrics generate/regenerate button lives in video-actions (next to Edit), not here. --}}
|
||||
|
||||
{{-- Bars visualiser toggle --}}
|
||||
<button class="ytp-button audio-bars-btn" id="ytpBarsBtn" title="Visualiser: Off">
|
||||
<svg viewBox="0 0 24 24">
|
||||
@@ -191,8 +265,48 @@
|
||||
{{-- Hidden audio element --}}
|
||||
<audio id="audioEl" src="{{ $audioUrl }}" preload="metadata"></audio>
|
||||
|
||||
@if($lyricsOwner && $lyricsAllowed)
|
||||
{{-- Lyrics editor modal (owner) — lives outside the player box --}}
|
||||
<div id="lyrEditorOverlay" class="lyr-editor-overlay" style="display:none">
|
||||
<div class="lyr-editor" role="dialog" aria-modal="true" aria-label="Edit lyrics">
|
||||
<div class="lyr-editor-hdr">
|
||||
<span>Edit Lyrics</span>
|
||||
<button type="button" class="lyr-editor-x" id="lyrEditorClose" aria-label="Close"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
<p class="lyr-editor-hint">Fix any misspelled words. Timing is preserved for lines you don't change.</p>
|
||||
<div class="lyr-editor-body" id="lyrEditorBody"></div>
|
||||
<div class="lyr-editor-ftr">
|
||||
<button type="button" class="action-btn" id="lyrEditorCancel"><span>Cancel</span></button>
|
||||
<button type="button" class="action-btn action-btn-primary" id="lyrEditorSave"><span>Save lyrics</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ══ CSS ══ --}}
|
||||
<style>
|
||||
/* Lyrics editor modal */
|
||||
.lyr-editor-overlay { position: fixed; inset: 0; z-index: 100000; background: rgba(0,0,0,.7);
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.lyr-editor { background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #333);
|
||||
border-radius: 16px; width: 100%; max-width: 640px; max-height: 86vh; display: flex; flex-direction: column;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.6); }
|
||||
.lyr-editor-hdr { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #333); font-weight: 700; font-size: 16px; color: var(--text-primary, #fff); }
|
||||
.lyr-editor-x { background: none; border: none; color: var(--text-primary, #fff); font-size: 16px; cursor: pointer; opacity: .7; }
|
||||
.lyr-editor-x:hover { opacity: 1; }
|
||||
.lyr-editor-hint { margin: 0; padding: 10px 20px; font-size: 12px; color: var(--text-secondary, #aaa); }
|
||||
.lyr-editor-body { overflow-y: auto; padding: 4px 20px 12px; flex: 1; }
|
||||
.lyr-editor-row { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
|
||||
.lyr-editor-time { flex-shrink: 0; width: 48px; font-size: 11px; color: var(--text-secondary, #999);
|
||||
font-variant-numeric: tabular-nums; cursor: pointer; }
|
||||
.lyr-editor-time:hover { color: var(--brand-red, #e61e1e); }
|
||||
.lyr-editor-input { flex: 1; background: var(--bg-primary, #121212); border: 1px solid var(--border-color, #333);
|
||||
border-radius: 8px; padding: 8px 10px; color: var(--text-primary, #fff); font-size: 14px; }
|
||||
.lyr-editor-input:focus { outline: none; border-color: var(--brand-red, #e61e1e); }
|
||||
.lyr-editor-input[lang-active="1"] { border-color: var(--brand-red, #e61e1e); }
|
||||
.lyr-editor-ftr { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-color, #333); }
|
||||
.audio-ytp { cursor: default; }
|
||||
.audio-cover-img {
|
||||
position: absolute;
|
||||
@@ -349,6 +463,69 @@
|
||||
.ytp-lang-check { width: 16px; height: 16px; fill: #fff; opacity: 0; flex-shrink: 0; }
|
||||
.ytp-lang-option.active .ytp-lang-check { opacity: 1; }
|
||||
|
||||
/* ── Synced lyrics overlay (one line at a time, bottom-anchored) ── */
|
||||
.ytp-lyrics-overlay {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 4;
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
pointer-events: none; padding: 0 4% 6%;
|
||||
}
|
||||
.ytp-lyrics-panel {
|
||||
max-width: 92%; text-align: center;
|
||||
padding: 12px 26px; border-radius: 14px;
|
||||
background: rgba(0,0,0,.5);
|
||||
-webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.45);
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
.ytp-lyrics-cur {
|
||||
font-family: 'Poppins', system-ui, -apple-system, sans-serif; font-weight: 800;
|
||||
font-size: clamp(18px, 2.7vw, 30px); line-height: 1.3; letter-spacing: .2px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,.85);
|
||||
}
|
||||
.ytp-lyrics-word { color: rgba(255,255,255,.5); transition: color .12s ease, text-shadow .12s ease; }
|
||||
.ytp-lyrics-word.sung { color: #fff; text-shadow: 0 0 14px rgba(255,45,45,.55), 0 2px 10px rgba(0,0,0,.85); }
|
||||
.ytp-lyrics-deco {
|
||||
display: inline-block; opacity: .9; margin: 0 .12em;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,.6));
|
||||
animation: ytpLyrPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.ytp-lyrics-deco.trail { animation-delay: .9s; }
|
||||
@keyframes ytpLyrPulse { 0%,100% { transform: scale(1); opacity: .85; } 50% { transform: scale(1.18); opacity: 1; } }
|
||||
.ytp-lyrics-status {
|
||||
font-family: 'Poppins', system-ui, sans-serif; font-weight: 600; font-size: 15px;
|
||||
color: rgba(255,255,255,.85); display: flex; align-items: center; gap: 10px; justify-content: center;
|
||||
}
|
||||
.ytp-lyrics-status .spin {
|
||||
width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3);
|
||||
border-top-color: #fff; border-radius: 50%; animation: ytpLyrSpin .8s linear infinite;
|
||||
}
|
||||
@keyframes ytpLyrSpin { to { transform: rotate(360deg); } }
|
||||
.ytp-lyrics-btn.active svg { fill: #ff2d2d; }
|
||||
/* Live generation progress bar */
|
||||
.ytp-lyrics-gen {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; z-index: 4;
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
pointer-events: none; padding: 0 4% 6%;
|
||||
}
|
||||
.ytp-lyrics-gen-inner {
|
||||
min-width: 280px; max-width: 84%;
|
||||
background: rgba(0,0,0,.55); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
|
||||
border-radius: 14px; padding: 12px 20px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
|
||||
}
|
||||
.ytp-lyrics-gen-row {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 9px;
|
||||
color: #fff; font-family: 'Poppins', system-ui, sans-serif; font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.ytp-lyrics-gen-label { flex: 1; text-align: left; }
|
||||
.ytp-lyrics-gen-pct { color: #ff6b6b; font-weight: 700; }
|
||||
.ytp-lyrics-gen-spark { animation: ytpLyrPulse 1.4s ease-in-out infinite; }
|
||||
.ytp-lyrics-gen-track { height: 6px; background: rgba(255,255,255,.18); border-radius: 4px; overflow: hidden; }
|
||||
.ytp-lyrics-gen-bar {
|
||||
height: 100%; width: 0%; border-radius: 4px;
|
||||
background: linear-gradient(90deg, #e61e1e, #ff6b6b);
|
||||
transition: width .35s ease;
|
||||
}
|
||||
|
||||
.ytp-settings-wrap { position: relative; }
|
||||
.ytp-settings-panel {
|
||||
display: none; position: absolute; bottom: 44px; right: 0;
|
||||
@@ -419,7 +596,20 @@
|
||||
.ytp-wrap { border-radius: 0; }
|
||||
.ytp-button { width: 32px; height: 32px; }
|
||||
.ytp-button svg { width: 18px; height: 18px; }
|
||||
.ytp-time-display { font-size: 11px; padding: 0 4px; }
|
||||
.ytp-time-display { font-size: 11px; padding: 0 3px; }
|
||||
.ytp-left-controls, .ytp-right-controls { gap: 1px; }
|
||||
/* The control bar was overflowing on phones and pushing the fullscreen button
|
||||
off-screen. Hide the two decorative/niche controls (visualiser + loop) on
|
||||
mobile so play, lyrics, language, speed and FULLSCREEN always fit. */
|
||||
.audio-bars-btn, .ytp-loop-btn { display: none !important; }
|
||||
/* Lyrics overlay: smaller font + single line on phones so a long verse never
|
||||
wraps and pushes the panel off the bottom of the artwork. */
|
||||
.ytp-lyrics-cur {
|
||||
font-size: clamp(12px, 3.6vw, 16px);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ytp-lyrics-panel { padding: 8px 14px; max-width: 96%; }
|
||||
.ytp-lyrics-overlay { padding: 0 2% 5%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -561,6 +751,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 changing it from another
|
||||
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';
|
||||
}
|
||||
}
|
||||
clearTimeout(hideTimer);
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
@@ -654,6 +855,12 @@ if (langBtn && langPopup) {
|
||||
// Update download links
|
||||
const dlUrl = opt.dataset.langDlUrl;
|
||||
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(l => { l.href = dlUrl; });
|
||||
|
||||
// Show this track's lyrics (from inline data)
|
||||
if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
|
||||
|
||||
// Swap the slideshow to this track's slides (with server-side fallback).
|
||||
if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -957,8 +1164,12 @@ function stopBars() {
|
||||
}
|
||||
|
||||
// ── Crossfade slideshow ───────────────────────────────────────
|
||||
// Variables hoisted outside the if-block so the SPA update hook can access them
|
||||
const SLIDE_URLS = @json($slideUrls);
|
||||
// Variables hoisted outside the if-block so the SPA update hook can access them.
|
||||
// SLIDE_MAP is keyed by track id ("0" = primary). Each entry already encodes the
|
||||
// fallback decided server-side by Video::slidesForTrack(), so a track with no
|
||||
// slides of its own gets the primary's (or a sibling's) automatically.
|
||||
window._SLIDE_MAP = @json($slideMap);
|
||||
const SLIDE_URLS = (window._SLIDE_MAP['0'] || []).slice();
|
||||
const slideA = document.getElementById('slideA');
|
||||
const slideB = document.getElementById('slideB');
|
||||
let currentSlide = 0;
|
||||
@@ -997,6 +1208,40 @@ function stopSlideshow() {
|
||||
slideshowTimer = null;
|
||||
}
|
||||
|
||||
// Replace the slide list at runtime — called when the user switches audio tracks.
|
||||
// Server-side fallback already applied (Video::slidesForTrack), so we trust the
|
||||
// incoming list verbatim. Empty → hide slideshow and show the cover image.
|
||||
window._applySlidesForTrack = function (trackId) {
|
||||
var key = String(parseInt(trackId, 10) || 0);
|
||||
var next = (window._SLIDE_MAP && window._SLIDE_MAP[key]) ? window._SLIDE_MAP[key].slice() : [];
|
||||
var coverEl = document.getElementById('audioCoverImg');
|
||||
var slideshowEl = document.getElementById('slideshowWrap');
|
||||
|
||||
stopSlideshow();
|
||||
SLIDE_URLS.length = 0;
|
||||
next.forEach(function (u) { SLIDE_URLS.push(u); });
|
||||
slideOrientations.length = 0;
|
||||
for (var i = 0; i < Math.max(SLIDE_URLS.length, 1); i++) slideOrientations.push(false);
|
||||
|
||||
if (SLIDE_URLS.length > 1) {
|
||||
if (coverEl) coverEl.style.display = 'none';
|
||||
if (slideshowEl) slideshowEl.style.display = '';
|
||||
currentSlide = 0; aIsTop = true;
|
||||
if (slideA) { slideA.style.transition = 'none'; slideA.src = SLIDE_URLS[0]; slideA.style.opacity = '1'; slideA.style.zIndex = '2'; }
|
||||
if (slideB) { slideB.style.transition = 'none'; slideB.src = SLIDE_URLS[1] || SLIDE_URLS[0]; slideB.style.opacity = '0'; slideB.style.zIndex = '1'; }
|
||||
requestAnimationFrame(function () { if (slideA) slideA.style.transition = ''; if (slideB) slideB.style.transition = ''; });
|
||||
SLIDE_URLS.forEach(function (url, idx) {
|
||||
var img = new Image();
|
||||
img.onload = function () { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
|
||||
img.src = url;
|
||||
});
|
||||
if (!audio.paused) startSlideshow();
|
||||
} else {
|
||||
if (slideshowEl) slideshowEl.style.display = 'none';
|
||||
if (coverEl) coverEl.style.display = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Always attach listeners; functions guard themselves with SLIDE_URLS.length check
|
||||
audio.addEventListener('play', startSlideshow);
|
||||
audio.addEventListener('pause', stopSlideshow);
|
||||
@@ -1038,11 +1283,15 @@ if (SLIDE_URLS.length > 1 && slideA) {
|
||||
|
||||
// ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
|
||||
window._audioPlayerUpdate = function(d) {
|
||||
var newSlides = (d.slides && d.slides.length > 1) ? d.slides : [];
|
||||
// Adopt the new song's per-track slide map. Fallback already applied server-side.
|
||||
window._SLIDE_MAP = d.slide_map || { '0': (d.slides || []) };
|
||||
var newSlides = (window._SLIDE_MAP['0'] && window._SLIDE_MAP['0'].length > 1)
|
||||
? window._SLIDE_MAP['0'] : [];
|
||||
var coverEl = document.getElementById('audioCoverImg');
|
||||
var slideshowEl = document.getElementById('slideshowWrap');
|
||||
|
||||
stopSlideshow();
|
||||
if (window._lyricsStop) window._lyricsStop(); // kill prev song's lyrics polling — don't pile up
|
||||
SLIDE_URLS.length = 0;
|
||||
|
||||
if (newSlides.length > 1) {
|
||||
@@ -1175,6 +1424,10 @@ window._audioPlayerUpdate = function(d) {
|
||||
// Update download links
|
||||
var dlUrl = opt.dataset.langDlUrl;
|
||||
if (dlUrl) document.querySelectorAll('.ytp-mp3-dl-link').forEach(function(l){ l.href = dlUrl; });
|
||||
// Show this track's lyrics (from inline data)
|
||||
if (window._lyricsShow) window._lyricsShow(parseInt(opt.dataset.langId, 10) || 0);
|
||||
// Swap the slideshow to this track's slides (with server-side fallback).
|
||||
if (window._applySlidesForTrack) window._applySlidesForTrack(opt.dataset.langId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1200,6 +1453,19 @@ window._audioPlayerUpdate = function(d) {
|
||||
if (scrubber) scrubber.parentElement.style.left = '0%';
|
||||
if (timeCur) timeCur.textContent = '0:00';
|
||||
if (timeDur && d.duration) timeDur.textContent = fmt(d.duration);
|
||||
|
||||
// New song → retarget the lyrics generate/poll endpoints to THIS song.
|
||||
// (The button's URL + poll key were baked in at server render for the first
|
||||
// song; without this, generating after navigation hits the previous song.)
|
||||
if (d.key) {
|
||||
window._ROUTE_KEY = d.key;
|
||||
var _genBtn = document.getElementById('ytpGenLyricsBtn');
|
||||
if (_genBtn) _genBtn.dataset.genUrl = '/videos/' + d.key + '/lyrics/generate';
|
||||
}
|
||||
|
||||
// New song → swap in its lyrics map (embedded in player-data) and show primary
|
||||
window._LYRICS = d.lyrics || {};
|
||||
if (window._lyricsShow) window._lyricsShow(0);
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
@@ -1222,6 +1488,14 @@ audio.addEventListener('playing', function restoreSound() {
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
timeDur.textContent = fmt(audio.duration);
|
||||
/* Resume handoff from the mini player: ?t=<sec> seeks to that position
|
||||
before play starts. ?resume=1 is implicit (the audio player already
|
||||
autoplays); we only need to honor the time. */
|
||||
try {
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const t = parseInt(qs.get('t') || '0', 10);
|
||||
if (t > 0 && t < audio.duration) audio.currentTime = t;
|
||||
} catch (e) {}
|
||||
const p = audio.play();
|
||||
if (p) p.catch(() => {
|
||||
audio.muted = true;
|
||||
@@ -1229,5 +1503,466 @@ audio.addEventListener('loadedmetadata', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Synced lyrics overlay (one line at a time, bottom-anchored) ──
|
||||
(function initLyrics() {
|
||||
const overlay = document.getElementById('ytpLyricsOverlay');
|
||||
const panel = document.getElementById('ytpLyricsPanel');
|
||||
const curEl = document.getElementById('ytpLyrCur');
|
||||
const btn = document.getElementById('ytpLyricsBtn');
|
||||
// The owner generate/regenerate button now lives in video-actions (next to
|
||||
// Edit) — look it up lazily so it works regardless of DOM order / SPA swaps.
|
||||
const gb = () => document.getElementById('ytpGenLyricsBtn');
|
||||
if (!overlay || !curEl || !btn) return;
|
||||
|
||||
let lines = [], activeLine = -2, curWordEls = [];
|
||||
let enabled = localStorage.getItem('ytpLyricsOn') === '1';
|
||||
let genPoll = null;
|
||||
|
||||
// Pick an emoji that reflects what the line is ABOUT (first keyword match
|
||||
// wins; more specific themes are listed first). Falls back to a music note.
|
||||
const EMOJI_MAP = [
|
||||
[/\b(heart\s?broken|heartbreak|broke\w*\s+\w*\s*heart|broken heart)\b/i, '💔'],
|
||||
[/\b(love|lovin|loving|adore|sweetheart|my heart|in love|darling)\b/i, '❤️'],
|
||||
[/\b(kiss|kisses|kissing|lips|lip gloss)\b/i, '💋'],
|
||||
[/\b(baby|babe|honey|boo)\b/i, '💕'],
|
||||
[/\b(fire|flame|flames|burn|burning|burns|lit|blaze|blazing)\b/i, '🔥'],
|
||||
[/\b(cry|crying|cried|tears|teardrop|weep|weeping)\b/i, '😢'],
|
||||
[/\b(sad|pain|painful|hurt|hurts|hurting|sorrow|lonely|alone|broken)\b/i, '😔'],
|
||||
[/\b(smile|smiling|happy|happiness|joy|joyful|laugh|laughing)\b/i, '😊'],
|
||||
[/\b(dance|dancing|dancin|groove|sway|move your)\b/i, '💃'],
|
||||
[/\b(party|partying|club|celebrate|turn up|tonight we)\b/i, '🎉'],
|
||||
[/\b(money|cash|rich|dollars?|gold|paid|bands|wealth|diamonds?)\b/i, '💰'],
|
||||
[/\b(king|queen|crown|royal|royalty|throne)\b/i, '👑'],
|
||||
[/\b(night|midnight|tonight|nighttime|dark|darkness|shadows?)\b/i, '🌙'],
|
||||
[/\b(sun|sunshine|sunrise|daylight|bright)\b/i, '☀️'],
|
||||
[/\b(star|stars|shine|shining|shinin|sparkle|glitter|glow)\b/i, '✨'],
|
||||
[/\b(heaven|heavens|sky|skies|clouds?)\b/i, '☁️'],
|
||||
[/\b(rain|raining|rainin|storm|stormy|thunder)\b/i, '🌧️'],
|
||||
[/\b(cold|ice|icy|frozen|freeze|winter|snow)\b/i, '❄️'],
|
||||
[/\b(ocean|sea|waves?|water|river|drown|drowning)\b/i, '🌊'],
|
||||
[/\b(rose|roses|flower|flowers|bloom|petals?)\b/i, '🌹'],
|
||||
[/\b(dream|dreams|dreaming|dreamin|asleep|sleep)\b/i, '💭'],
|
||||
[/\b(fly|flying|flyin|wings|soar|rise|rising)\b/i, '🕊️'],
|
||||
[/\b(rocket|space|moon|sky high|to the moon)\b/i, '🚀'],
|
||||
[/\b(run|running|runnin|ran|escape|escaping|away)\b/i, '🏃'],
|
||||
[/\b(time|clock|hours?|minutes?|forever|seconds?)\b/i, '⏳'],
|
||||
[/\b(god|pray|prayin|prayer|soul|angel|angels|amen|bless)\b/i, '🙏'],
|
||||
[/\b(home|house|hometown)\b/i, '🏠'],
|
||||
[/\b(road|drive|driving|drivin|car|ride|riding|highway)\b/i, '🚗'],
|
||||
[/\b(wild|crazy|insane|reckless|savage|chaos)\b/i, '🤪'],
|
||||
[/\b(strong|power|powerful|stronger|unstoppable)\b/i, '💪'],
|
||||
[/\b(eyes?|look|looking|lookin|stare|staring|gaze)\b/i, '👀'],
|
||||
[/\b(drink|wine|champagne|whiskey|drunk|toast|cheers)\b/i, '🥂'],
|
||||
[/\b(war|fight|fighting|battle|enemy|enemies|trouble)\b/i, '⚔️'],
|
||||
[/\b(devil|hell|sin|sinner|demons?)\b/i, '😈'],
|
||||
[/\b(phone|call|calling|text|message|ring)\b/i, '📱'],
|
||||
[/\b(music|song|sing|singing|singin|melody|beat|rhythm|sound)\b/i, '🎶'],
|
||||
];
|
||||
function emojiForLine(text) {
|
||||
const s = ' ' + String(text || '') + ' ';
|
||||
for (let i = 0; i < EMOJI_MAP.length; i++) { if (EMOJI_MAP[i][0].test(s)) return EMOJI_MAP[i][1]; }
|
||||
return '🎵';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
function showBtn(show) {
|
||||
btn.style.display = show ? '' : 'none';
|
||||
if (!show) { overlay.style.display = 'none'; btn.classList.remove('active'); }
|
||||
}
|
||||
function applyEnabled() {
|
||||
btn.classList.toggle('active', enabled);
|
||||
overlay.style.display = (enabled && lines.length) ? 'flex' : 'none';
|
||||
}
|
||||
function render(data) {
|
||||
lines = (data && data.lines) ? data.lines : [];
|
||||
activeLine = -2; curWordEls = [];
|
||||
curEl.innerHTML = '';
|
||||
if (!lines.length) { showBtn(false); return; }
|
||||
showBtn(true);
|
||||
applyEnabled();
|
||||
sync(true);
|
||||
}
|
||||
function findLine(t) {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < lines.length; i++) { if (lines[i].start <= t) idx = i; else break; }
|
||||
return idx;
|
||||
}
|
||||
// Render only the single active line, wrapped in cycling decorative emojis.
|
||||
function paintLine(idx) {
|
||||
if (idx < 0 || !lines[idx]) {
|
||||
// No line yet (before the song's first lyric) — hide the panel so
|
||||
// there's no empty black box sitting on the artwork.
|
||||
curEl.innerHTML = '';
|
||||
curWordEls = [];
|
||||
if (panel) panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (panel) panel.style.display = '';
|
||||
const ln = lines[idx];
|
||||
const words = (ln.words && ln.words.length) ? ln.words : null;
|
||||
const inner = words
|
||||
? words.map((w, j) => '<span class="ytp-lyrics-word" data-i="' + j + '">' + escapeHtml(w.text) + '</span>').join(' ')
|
||||
: '<span class="ytp-lyrics-word">' + escapeHtml(ln.text || '') + '</span>';
|
||||
// If the LLM decorated the line at generation time, emojis are already
|
||||
// inside ln.text — render it bare without the lead/trail wrap so we
|
||||
// don't double-stack decorations. Old un-decorated lyrics keep the
|
||||
// keyword-emoji fallback (or a baked single emoji from the v1 format).
|
||||
if (ln.decorated) {
|
||||
curEl.innerHTML = inner;
|
||||
} else {
|
||||
const e = (ln.emoji && String(ln.emoji).trim()) || emojiForLine(ln.text);
|
||||
curEl.innerHTML = '<span class="ytp-lyrics-deco lead">' + e + '</span>' + inner
|
||||
+ '<span class="ytp-lyrics-deco trail">' + e + '</span>';
|
||||
}
|
||||
curWordEls = [].slice.call(curEl.querySelectorAll('.ytp-lyrics-word'));
|
||||
}
|
||||
function sync(force) {
|
||||
if (!enabled || !lines.length) return;
|
||||
const t = audio.currentTime || 0;
|
||||
let idx = findLine(t);
|
||||
// During an instrumental gap (well past the current line and the next one
|
||||
// hasn't started) hide the line instead of leaving it frozen on screen.
|
||||
if (idx >= 0 && t > lines[idx].end + 2.5) {
|
||||
const next = lines[idx + 1];
|
||||
if (!next || next.start - t > 0.4) idx = -1;
|
||||
}
|
||||
if (idx !== activeLine || force) {
|
||||
paintLine(idx);
|
||||
activeLine = idx;
|
||||
}
|
||||
if (idx >= 0 && curWordEls.length) {
|
||||
const ws = lines[idx].words || [];
|
||||
curWordEls.forEach((span, j) => { const w = ws[j]; if (w) span.classList.toggle('sung', t >= w.start); });
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', () => sync(false));
|
||||
audio.addEventListener('seeked', () => sync(true));
|
||||
btn.addEventListener('click', () => {
|
||||
enabled = !enabled;
|
||||
localStorage.setItem('ytpLyricsOn', enabled ? '1' : '0');
|
||||
applyEnabled();
|
||||
if (enabled) sync(true);
|
||||
});
|
||||
|
||||
// Owner-only generate button + live progress panel.
|
||||
const genWrap = document.getElementById('ytpLyrGen');
|
||||
const genBar = document.getElementById('ytpLyrGenBar');
|
||||
const genLabel = document.getElementById('ytpLyrGenLabel');
|
||||
const genPct = document.getElementById('ytpLyrGenPct');
|
||||
let dispPct = 0, targetPct = 0, creepTimer = null;
|
||||
|
||||
function showGen(show, mode) {
|
||||
const b = gb();
|
||||
if (!b) return;
|
||||
b.style.display = show ? '' : 'none';
|
||||
const label = mode === 'regen' ? 'Regenerate lyrics' : 'Generate lyrics';
|
||||
b.title = label;
|
||||
b.classList.toggle('is-regen', mode === 'regen');
|
||||
const lbl = b.querySelector('.genlyr-label');
|
||||
if (lbl) lbl.textContent = label;
|
||||
}
|
||||
function showGenProgress(on) {
|
||||
if (genWrap) genWrap.style.display = on ? 'flex' : 'none';
|
||||
if (on && overlay) overlay.style.display = 'none'; // avoid overlapping the lyrics panel
|
||||
if (!on && creepTimer) { clearInterval(creepTimer); creepTimer = null; }
|
||||
}
|
||||
function setBar(p) {
|
||||
if (genBar) genBar.style.width = p + '%';
|
||||
if (genPct) genPct.textContent = Math.round(p) + '%';
|
||||
}
|
||||
|
||||
// Stop any in-flight generation polling/timers (called on navigation + track
|
||||
// switches so nothing piles up as the user moves between songs).
|
||||
window._lyricsStop = function () {
|
||||
if (genPoll) { clearInterval(genPoll.timer); genPoll = null; }
|
||||
if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
|
||||
showGenProgress(false);
|
||||
};
|
||||
|
||||
// Show lyrics for a track id (0 = primary) straight from the inline map —
|
||||
// no network request. window._LYRICS is keyed by track id as a string.
|
||||
window._lyricsShow = function (trackId) {
|
||||
const tid = parseInt(trackId, 10) || 0;
|
||||
// Switching to a different track → kill a poll left running for another track.
|
||||
if (genPoll && genPoll.track !== tid) window._lyricsStop();
|
||||
const d = (window._LYRICS || {})[String(tid)];
|
||||
if (d && d.status === 'ready' && d.lines && d.lines.length) {
|
||||
// Lyrics exist → show them, keep the owner's "Regenerate" + "Edit" buttons.
|
||||
render(d); showGenProgress(false); showGen(true, 'regen'); showEdit(true); showDelete(true);
|
||||
} else if (d && d.status === 'processing') {
|
||||
// Generation already running (e.g. right after upload) — show the live bar.
|
||||
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
|
||||
if (genWrap) { if (!genPoll || genPoll.track !== tid) startGenPoll(tid); }
|
||||
else { showGen(false); }
|
||||
} else {
|
||||
lines = []; activeLine = -2; showBtn(false); showEdit(false); showDelete(false);
|
||||
if (genPoll && genPoll.track === tid) { /* in flight — keep the bar */ }
|
||||
else { showGenProgress(false); showGen(!!gb()); }
|
||||
}
|
||||
};
|
||||
|
||||
// Owner clicks the generate/regenerate button (delegated so it works wherever
|
||||
// the button is rendered and after SPA content swaps) → kick off generation,
|
||||
// then watch the live progress bar in the player.
|
||||
document.addEventListener('click', function (ev) {
|
||||
const b = ev.target.closest && ev.target.closest('#ytpGenLyricsBtn');
|
||||
if (!b) return;
|
||||
ev.preventDefault();
|
||||
// Close the settings panel if the click came from inside the gear menu.
|
||||
const sp = document.getElementById('ytpSettingsPanel');
|
||||
if (sp) sp.classList.remove('open');
|
||||
const track = window._ytpTrackId || 0;
|
||||
const existing = (window._LYRICS || {})[String(track)];
|
||||
const isRegen = !!(existing && existing.status === 'ready' && existing.lines && existing.lines.length);
|
||||
const restore = () => showGen(true, isRegen ? 'regen' : 'gen');
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
|
||||
const url = b.dataset.genUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics/generate');
|
||||
showGen(false); showGenProgress(true);
|
||||
dispPct = 1; targetPct = 1; setBar(1);
|
||||
if (genLabel) genLabel.textContent = isRegen ? 'Regenerating…' : 'Starting…';
|
||||
if (window.showToast) window.showToast((isRegen ? 'Regenerating' : 'Generating') + ' lyrics…', 'info');
|
||||
fetch(url + '?track=' + track, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.error) { showGenProgress(false); restore(); if (window.showToast) window.showToast(res.error, 'error'); return; }
|
||||
startGenPoll(track);
|
||||
})
|
||||
.catch(() => { showGenProgress(false); restore(); if (window.showToast) window.showToast('Could not start generation.', 'error'); });
|
||||
});
|
||||
|
||||
// Poll the live progress endpoint; creep the bar between polls so it always moves.
|
||||
function startGenPoll(track) {
|
||||
if (genPoll) clearInterval(genPoll.timer);
|
||||
showGen(false); showGenProgress(true);
|
||||
dispPct = Math.max(dispPct, 1); targetPct = Math.max(targetPct, 1);
|
||||
if (genLabel) genLabel.textContent = 'Generating lyrics…';
|
||||
setBar(dispPct);
|
||||
if (creepTimer) clearInterval(creepTimer);
|
||||
creepTimer = setInterval(() => {
|
||||
const soft = Math.min(targetPct + 6, 97);
|
||||
if (dispPct < soft) { dispPct = Math.min(soft, dispPct + 0.6); setBar(dispPct); }
|
||||
}, 220);
|
||||
|
||||
const key = window._ROUTE_KEY || @json($video->getRouteKey());
|
||||
let misses = 0, ticks = 0;
|
||||
const timer = setInterval(() => {
|
||||
// Hard cap (~8 min) so a stuck job can never leave a poll running forever.
|
||||
if (++ticks > 320) { window._lyricsStop(); showGen(!!gb()); return; }
|
||||
fetch('/videos/' + key + '/lyrics/progress?track=' + track, { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(p => {
|
||||
if (p.status === 'ready') {
|
||||
targetPct = 100; dispPct = 100; setBar(100);
|
||||
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
|
||||
genPoll = null;
|
||||
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
window._LYRICS = d.lyrics || window._LYRICS;
|
||||
setTimeout(() => {
|
||||
showGenProgress(false);
|
||||
if ((window._ytpTrackId || 0) === track) window._lyricsShow(track); else showGen(true);
|
||||
if (window.showToast) window.showToast('Lyrics ready!', 'success');
|
||||
}, 400);
|
||||
})
|
||||
.catch(() => { showGenProgress(false); window._lyricsShow(track); });
|
||||
} else if (p.status === 'failed') {
|
||||
clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; }
|
||||
genPoll = null; showGenProgress(false); showGen(true);
|
||||
if (window.showToast) window.showToast('Lyrics generation failed.', 'error');
|
||||
} else if (p.status === 'none') {
|
||||
if (++misses > 6) { clearInterval(timer); if (creepTimer) { clearInterval(creepTimer); creepTimer = null; } genPoll = null; showGenProgress(false); showGen(true); }
|
||||
} else {
|
||||
misses = 0;
|
||||
if (typeof p.pct === 'number') targetPct = Math.max(targetPct, p.pct);
|
||||
if (genLabel && p.stage) genLabel.textContent = p.stage + '…';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 1500);
|
||||
genPoll = { track: track, timer: timer };
|
||||
}
|
||||
|
||||
// ── Lyrics editor (owner) ──────────────────────────────────────────────
|
||||
function showEdit(show) {
|
||||
const e = document.getElementById('ytpEditLyricsBtn');
|
||||
if (e) e.style.display = show ? '' : 'none';
|
||||
}
|
||||
function showDelete(show) {
|
||||
const e = document.getElementById('ytpDeleteLyricsBtn');
|
||||
if (e) e.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
// Owner clicks Delete lyrics (gear menu) → wipe local + NAS copy, reset the
|
||||
// overlay so they can regenerate from scratch. Confirmation via toast-style
|
||||
// inline question; we never use alert/confirm (project rule).
|
||||
document.addEventListener('click', function (ev) {
|
||||
const b = ev.target.closest && ev.target.closest('#ytpDeleteLyricsBtn');
|
||||
if (!b) return;
|
||||
ev.preventDefault();
|
||||
const sp = document.getElementById('ytpSettingsPanel');
|
||||
if (sp) sp.classList.remove('open');
|
||||
|
||||
const track = window._ytpTrackId || 0;
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
|
||||
const url = b.dataset.delUrl || ('/videos/' + (window._ROUTE_KEY || '') + '/lyrics');
|
||||
|
||||
if (window.showToast) window.showToast('Deleting lyrics…', 'info');
|
||||
fetch(url + '?track=' + track, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
if (window.showToast) window.showToast(res.error, 'error');
|
||||
return;
|
||||
}
|
||||
// Clear cached lyrics for this track so the overlay disappears and
|
||||
// the gear button flips back to "Generate" (not "Regenerate").
|
||||
if (window._LYRICS) delete window._LYRICS[String(track)];
|
||||
lines = []; activeLine = -2; curWordEls = [];
|
||||
curEl.innerHTML = '';
|
||||
if (panel) panel.style.display = 'none';
|
||||
overlay.style.display = 'none';
|
||||
showBtn(false); showEdit(false); showDelete(false);
|
||||
showGenProgress(false); showGen(!!gb(), 'gen');
|
||||
if (window.showToast) window.showToast('Lyrics deleted. Click Generate to start fresh.', 'success');
|
||||
})
|
||||
.catch(() => { if (window.showToast) window.showToast('Could not delete lyrics.', 'error'); });
|
||||
});
|
||||
const edOverlay = document.getElementById('lyrEditorOverlay');
|
||||
const edBody = document.getElementById('lyrEditorBody');
|
||||
let edTrack = 0, edLines = [];
|
||||
|
||||
function fmtTime(s) {
|
||||
s = Math.max(0, Math.floor(s || 0));
|
||||
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
||||
}
|
||||
window.openLyricsEditor = function () {
|
||||
const tid = window._ytpTrackId || 0;
|
||||
const d = (window._LYRICS || {})[String(tid)];
|
||||
if (!d || d.status !== 'ready' || !d.lines || !d.lines.length) {
|
||||
if (window.showToast) window.showToast('Generate lyrics first.', 'info');
|
||||
return;
|
||||
}
|
||||
edTrack = tid;
|
||||
edLines = JSON.parse(JSON.stringify(d.lines)); // editable copy (keeps words)
|
||||
edBody.innerHTML = '';
|
||||
edLines.forEach((ln, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'lyr-editor-row';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'lyr-editor-time';
|
||||
time.textContent = fmtTime(ln.start);
|
||||
time.title = 'Jump to this line';
|
||||
time.addEventListener('click', () => { try { audio.currentTime = ln.start; audio.play().catch(()=>{}); } catch(e){} });
|
||||
const inp = document.createElement('input');
|
||||
inp.className = 'lyr-editor-input';
|
||||
inp.type = 'text';
|
||||
inp.value = ln.text || '';
|
||||
inp.addEventListener('input', () => { edLines[i].text = inp.value; });
|
||||
row.appendChild(time); row.appendChild(inp);
|
||||
edBody.appendChild(row);
|
||||
});
|
||||
edOverlay.style.display = 'flex';
|
||||
};
|
||||
function closeEditor() { if (edOverlay) edOverlay.style.display = 'none'; }
|
||||
if (edOverlay) {
|
||||
document.getElementById('lyrEditorClose').addEventListener('click', closeEditor);
|
||||
document.getElementById('lyrEditorCancel').addEventListener('click', closeEditor);
|
||||
edOverlay.addEventListener('click', e => { if (e.target === edOverlay) closeEditor(); });
|
||||
document.getElementById('lyrEditorSave').addEventListener('click', () => {
|
||||
const saveBtn = document.getElementById('lyrEditorSave');
|
||||
saveBtn.disabled = true;
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const token = (typeof csrf !== 'undefined' ? csrf : '') || (csrfMeta ? csrfMeta.getAttribute('content') : '');
|
||||
const key = window._ROUTE_KEY || @json($video->getRouteKey());
|
||||
fetch('/videos/' + key + '/lyrics/save', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track: edTrack, lines: edLines })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
saveBtn.disabled = false;
|
||||
if (res.error) { if (window.showToast) window.showToast(res.error, 'error'); return; }
|
||||
// Reload the fresh (re-timed) lyrics and re-render.
|
||||
fetch('/videos/' + key + '/player-data', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
window._LYRICS = d.lyrics || window._LYRICS;
|
||||
if ((window._ytpTrackId || 0) === edTrack) window._lyricsShow(edTrack);
|
||||
})
|
||||
.catch(() => {});
|
||||
closeEditor();
|
||||
if (window.showToast) window.showToast('Lyrics saved.', 'success');
|
||||
})
|
||||
.catch(() => { saveBtn.disabled = false; if (window.showToast) window.showToast('Could not save lyrics.', 'error'); });
|
||||
});
|
||||
}
|
||||
|
||||
// Lyrics for the server-rendered song, embedded inline (keyed by track id).
|
||||
window._LYRICS = @json($inlineLyrics, JSON_UNESCAPED_UNICODE);
|
||||
window._lyricsShow(window._ytpTrackId || 0);
|
||||
})();
|
||||
|
||||
/* ── Scroll-based mini player for the music view ────────────────────────
|
||||
Mirrors the IntersectionObserver in video-player.blade.php. Activates the
|
||||
global #ytpMini once playback has started AND the player wrap leaves the
|
||||
viewport; deactivates when it scrolls back in.
|
||||
|
||||
Deferred to DOMContentLoaded because window._miniPlayer is defined in the
|
||||
layout's footer scripts, which parse after this partial. */
|
||||
function _setupAudioMiniObserver() {
|
||||
if (!window.IntersectionObserver || !window._miniPlayer) return;
|
||||
var wrapEl = document.getElementById('ytpWrap');
|
||||
var aEl = document.getElementById('audioEl');
|
||||
if (!wrapEl || !aEl) return;
|
||||
|
||||
/* Floating mini player is desktop-only — on mobile the fixed bottom-nav
|
||||
and the locked scroll model make a floating overlay disruptive. */
|
||||
if (window.innerWidth <= 768) return;
|
||||
|
||||
var _scrollRoot = null; /* desktop: window scrolls */
|
||||
var _scrollMiniOn = false;
|
||||
var _hasPlayed = !aEl.paused;
|
||||
aEl.addEventListener('play', function () { _hasPlayed = true; });
|
||||
|
||||
new IntersectionObserver(function (entries) {
|
||||
var e0 = entries[0];
|
||||
var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
|
||||
if (!e0.isIntersecting && !_scrollMiniOn && _hasPlayed && miniAllowed && !window._miniPlayer.isNavMode()) {
|
||||
_scrollMiniOn = true;
|
||||
window._miniPlayer.activateScroll(
|
||||
document.title.replace(/\s*\|.*$/, '').trim(),
|
||||
window.location.href
|
||||
);
|
||||
} else if (e0.isIntersecting && _scrollMiniOn) {
|
||||
_scrollMiniOn = false;
|
||||
window._miniPlayer.deactivateScroll();
|
||||
}
|
||||
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrapEl);
|
||||
|
||||
/* User clicked the X on the mini while still on this page — reset our
|
||||
local flag so a subsequent scroll-away re-activates the mini. */
|
||||
window.addEventListener('miniplayer:scroll-closed', function () {
|
||||
_scrollMiniOn = false;
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _setupAudioMiniObserver);
|
||||
} else {
|
||||
_setupAudioMiniObserver();
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
@@ -1,20 +1,25 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('main_class', 'video-view-page')
|
||||
@section('title', $video->title . ' | ' . config('app.name'))
|
||||
@php
|
||||
$metaTitle = $shareTitle ?? $video->title;
|
||||
$metaDesc = $shareDescription ?? $video->description;
|
||||
$metaUrl = $video->share_url;
|
||||
@endphp
|
||||
@section('title', $metaTitle . ' | ' . config('app.name'))
|
||||
|
||||
@push('head')
|
||||
<meta property="og:title" content="{{ $video->title }}">
|
||||
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
|
||||
<meta property="og:title" content="{{ $metaTitle }}">
|
||||
<meta property="og:description" content="{{ Str::limit(strip_tags($metaDesc ?? config('app.name') . ' — watch now'), 200) }}">
|
||||
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:url" content="{{ $video->share_url }}">
|
||||
<meta property="og:url" content="{{ $metaUrl }}">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:site_name" content="{{ config('app.name') }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $video->title }}">
|
||||
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
|
||||
<meta name="twitter:title" content="{{ $metaTitle }}">
|
||||
<meta name="twitter:description" content="{{ Str::limit(strip_tags($metaDesc ?? ''), 200) }}">
|
||||
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
|
||||
@endpush
|
||||
|
||||
@@ -226,6 +231,52 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn,
|
||||
.comments-section .action-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover,
|
||||
.comments-section .action-btn:hover {
|
||||
background: var(--border-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:active,
|
||||
.comments-section .action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.action-btn svg,
|
||||
.action-btn i,
|
||||
.comments-section .action-btn svg,
|
||||
.comments-section .action-btn i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn.comment-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
|
||||
/* Description */
|
||||
@@ -374,7 +425,15 @@
|
||||
margin: 12px 0 6px !important;
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
width: 100%;
|
||||
@@ -418,7 +477,7 @@
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
{{-- Generic video: always a single video track (mp4/HLS), GPU-encoded server-side --}}
|
||||
<x-video-player :video="$video"
|
||||
:next-video="$nextVideo ?? null"
|
||||
:previous-video="$previousVideo ?? null"
|
||||
@@ -426,9 +485,11 @@
|
||||
:playlist-videos="$playlistVideos ?? null" />
|
||||
|
||||
<!-- Video Title with Film Icon (Generic Type) -->
|
||||
@php $titleLangFlag = \App\Data\Languages::flag($video->language); @endphp
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi bi-film" style="color: #ef4444;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
<i class="bi bi-film" style="color: #ef4444; flex-shrink:0;"></i>
|
||||
<span class="fi fi-{{ $titleLangFlag ?: 'xx' }}" id="videoTitleFlag" style="width:22px;height:16px;border-radius:2px;display:{{ $titleLangFlag ? 'inline-block' : 'none' }};flex-shrink:0;"></span>
|
||||
<span id="videoTitleText" data-primary-title="{{ $video->title }}">{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Stats Row - Hidden, shown in description box -->
|
||||
@@ -440,11 +501,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Row - All in one line -->
|
||||
<x-channel-row :video="$video" />
|
||||
|
||||
@include('videos.partials.description-box', ['video' => $video])
|
||||
|
||||
<x-video-comments :video="$video" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
@@ -493,22 +556,24 @@
|
||||
function plGet(k,d){ var v=localStorage.getItem('pl_'+k+'_'+PL_ID); return v!==null?v:d; }
|
||||
function plSet(k,v){ localStorage.setItem('pl_'+k+'_'+PL_ID,v); }
|
||||
|
||||
var plAutoplay = plGet('autoplay','1');
|
||||
var plLoop = plGet('loop','off');
|
||||
var plShuffle = plGet('shuffle','0');
|
||||
var plTransiting = false;
|
||||
var plAutoplay = plGet('autoplay','1');
|
||||
var plLoop = plGet('loop','off');
|
||||
var plShuffle = plGet('shuffle','0');
|
||||
var plTransiting = false;
|
||||
|
||||
// ── shuffle helpers ──────────────────────────────────────
|
||||
function plShuffleOrder(){ var o=PL_VIDEOS.map(function(_,i){return i;}); for(var i=o.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=o[i];o[i]=o[j];o[j]=t;} localStorage.setItem('pl_shuffleOrder_'+PL_ID,JSON.stringify(o)); return o; }
|
||||
function plGetOrder(){ var r=localStorage.getItem('pl_shuffleOrder_'+PL_ID); if(r){try{return JSON.parse(r);}catch(e){}} return null; }
|
||||
|
||||
// ── compute adjacent URLs from current state ──────────────
|
||||
function plAdj(curId) {
|
||||
var idx=PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
|
||||
if(plShuffle==='1'){
|
||||
var idx = PL_VIDEOS.findIndex(function(v){ return v.id===curId; });
|
||||
if (plShuffle==='1') {
|
||||
var ord=plGetOrder()||plShuffleOrder();
|
||||
var pos=ord.indexOf(idx);
|
||||
var pp=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
|
||||
var np=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
|
||||
return { prev:pp>=0?PL_VIDEOS[ord[pp]].url:'', next:np>=0?PL_VIDEOS[ord[np]].url:'' };
|
||||
var prevPos=pos>0?pos-1:(plLoop==='all'?ord.length-1:-1);
|
||||
var nextPos=pos<ord.length-1?pos+1:(plLoop==='all'?0:-1);
|
||||
return { prev: prevPos>=0?PL_VIDEOS[ord[prevPos]].url:'', next: nextPos>=0?PL_VIDEOS[ord[nextPos]].url:'' };
|
||||
}
|
||||
return {
|
||||
prev: idx>0?PL_VIDEOS[idx-1].url:'',
|
||||
@@ -516,27 +581,40 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ── time formatter ───────────────────────────────────────
|
||||
function plFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||
|
||||
// ── SPA transition — load new video source + update UI ────
|
||||
async function plTransitionTo(url, pushHist) {
|
||||
if(!url||plTransiting) return;
|
||||
plTransiting=true;
|
||||
if(window._spaScrollToVideo) window._spaScrollToVideo();
|
||||
if (!url || plTransiting) return;
|
||||
plTransiting = true;
|
||||
if (window._spaScrollToVideo) window._spaScrollToVideo();
|
||||
try {
|
||||
var m=url.match(/\/videos\/([^/?#]+)/);
|
||||
if(!m){ window.location.href=url; return; }
|
||||
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
|
||||
var m = url.match(/\/videos\/([^/?#]+)/);
|
||||
if (!m) { window.location.href=url; return; }
|
||||
var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
|
||||
var resp;
|
||||
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
|
||||
catch(e){ window.location.href=url; return; }
|
||||
if(!resp.ok){ window.location.href=url; return; }
|
||||
try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
|
||||
catch(e) { window.location.href=url; return; }
|
||||
if (!resp.ok) { window.location.href=url; return; }
|
||||
var d;
|
||||
try { d=await resp.json(); }
|
||||
catch(e){ window.location.href=url; return; }
|
||||
try { d = await resp.json(); }
|
||||
catch(e) { window.location.href=url; return; }
|
||||
|
||||
// reload video source (HLS or MP4)
|
||||
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
|
||||
catch(e){ console.warn('_ytpLoadSource', e); }
|
||||
// Different player type? Fall back to hard nav so the
|
||||
// correct type view (music/match) handles playback.
|
||||
if (d.type && d.type !== 'generic') { window.location.href = url; return; }
|
||||
|
||||
// preserve volume/muted across source swap
|
||||
var vid = document.getElementById('videoPlayer');
|
||||
var _savedVol = vid ? vid.volume : null;
|
||||
var _savedMuted = vid ? vid.muted : null;
|
||||
|
||||
// swap video source (HLS preferred, MP4 fallback)
|
||||
try { if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
|
||||
catch(e) { console.warn('_ytpLoadSource', e); }
|
||||
|
||||
if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
|
||||
|
||||
// reset progress bar
|
||||
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
|
||||
@@ -547,40 +625,47 @@
|
||||
if(dr&&d.duration) dr.textContent=plFmt(d.duration);
|
||||
|
||||
// update page title
|
||||
var ts=document.querySelector('.video-title span');
|
||||
if(ts) ts.textContent=d.title;
|
||||
var ts=document.getElementById('videoTitleText');
|
||||
if(ts){ ts.textContent=d.title; ts.dataset.primaryTitle=d.title; }
|
||||
document.title=d.title+' | {{ config("app.name") }}';
|
||||
|
||||
// update loop state on video element
|
||||
var vid=document.getElementById('videoPlayer');
|
||||
if(vid) vid.loop=(plLoop==='one');
|
||||
|
||||
PL_CURRENT=d.id;
|
||||
try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
|
||||
// update state
|
||||
PL_CURRENT = d.id;
|
||||
try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
|
||||
|
||||
// history
|
||||
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
|
||||
|
||||
// async: swap description + comments from fetched page
|
||||
plSwapContent(url);
|
||||
} catch(e){
|
||||
} catch(e) {
|
||||
console.warn('plTransitionTo',e);
|
||||
} finally {
|
||||
plTransiting=false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── background page swap: description + comments ──────────
|
||||
async function plSwapContent(url) {
|
||||
try {
|
||||
var resp=await fetch(url,{headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
|
||||
var html=await resp.text();
|
||||
var doc=new DOMParser().parseFromString(html,'text/html');
|
||||
var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
|
||||
var html = await resp.text();
|
||||
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(window._vdbCheckOverflow)_vdbCheckOverflow();}
|
||||
// swap description box
|
||||
var nv=doc.getElementById('vdbWrap'), ov=document.getElementById('vdbWrap');
|
||||
if(nv&&ov){ ov.innerHTML=nv.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
|
||||
|
||||
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
|
||||
// swap channel row
|
||||
var nc=doc.querySelector('.channel-row'), oc=document.querySelector('.channel-row');
|
||||
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||
|
||||
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
|
||||
if(ny&&oy){
|
||||
// swap comments (HTML + re-run its init script)
|
||||
var ny=doc.getElementById('ytcSection'), oy=document.getElementById('ytcSection');
|
||||
if(ny&&oy) {
|
||||
oy.innerHTML=ny.innerHTML;
|
||||
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
|
||||
if(ytcScript){
|
||||
@@ -593,13 +678,27 @@
|
||||
} catch(e){ console.warn('plSwapContent',e); }
|
||||
}
|
||||
|
||||
function plHighlight(activeId, scroll){
|
||||
// ── sidebar highlight ────────────────────────────────────
|
||||
function plHighlight(activeId, scroll) {
|
||||
document.querySelectorAll('.sidebar-video-card[data-pl-id]').forEach(function(c){
|
||||
c.classList.toggle('current-video',parseInt(c.dataset.plId)===activeId);
|
||||
c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
|
||||
});
|
||||
if(scroll){ var a=document.querySelector('.sidebar-video-card.current-video'); if(a) a.scrollIntoView({behavior:'smooth',block:'nearest'}); }
|
||||
if(scroll) {
|
||||
// Scroll only inside the sidebar list container — never the page —
|
||||
// so focus stays on the player when the track changes.
|
||||
var active = document.querySelector('.sidebar-video-card.current-video');
|
||||
if (!active) return;
|
||||
var list = active.closest('.recommended-videos-list') || active.parentElement;
|
||||
if (list) {
|
||||
var lr = list.getBoundingClientRect();
|
||||
var ar = active.getBoundingClientRect();
|
||||
var delta = (ar.top + ar.height/2) - (lr.top + lr.height/2);
|
||||
list.scrollTo({ top: list.scrollTop + delta, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── render control button states ─────────────────────────
|
||||
function plRender(){
|
||||
var adj=plAdj(PL_CURRENT);
|
||||
var sb=document.getElementById('plShuffleBtn'),lb=document.getElementById('plLoopBtn');
|
||||
@@ -614,14 +713,15 @@
|
||||
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
|
||||
if(nb) nb.disabled=!adj.next;
|
||||
if(pb) pb.disabled=!adj.prev;
|
||||
// In-player prev/next buttons
|
||||
document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
|
||||
document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
|
||||
}
|
||||
|
||||
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
|
||||
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
|
||||
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
|
||||
window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
|
||||
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
|
||||
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
|
||||
// ── public API ────────────────────────────────────────────
|
||||
window.plGoTo = function(url){ if(url) plTransitionTo(url); };
|
||||
window.plNext = function(){ var a=plAdj(PL_CURRENT); if(a.next) plTransitionTo(a.next); };
|
||||
window.plPrev = function(){ var a=plAdj(PL_CURRENT); if(a.prev) plTransitionTo(a.prev); };
|
||||
|
||||
// hook into video player: intercept next/prev and ended
|
||||
window._ytpNavOverride = {
|
||||
@@ -630,8 +730,14 @@
|
||||
};
|
||||
window._plOnVideoEnd = function(){ if(plLoop==='one') return; if(plAutoplay==='1') window.plNext(); };
|
||||
|
||||
window.addEventListener('popstate',function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url,false); });
|
||||
window.plToggleShuffle = function(){ plShuffle=plShuffle==='1'?'0':'1'; plSet('shuffle',plShuffle); if(plShuffle==='1') plShuffleOrder(); else localStorage.removeItem('pl_shuffleOrder_'+PL_ID); plRender(); };
|
||||
window.plToggleLoop = function(){ var s=['off','all','one']; var i=s.indexOf(plLoop); plLoop=s[(i+1)%s.length]; plSet('loop',plLoop); plRender(); var v=document.getElementById('videoPlayer'); if(v) v.loop=(plLoop==='one'); };
|
||||
window.plToggleAutoplay = function(){ plAutoplay=plAutoplay==='1'?'0':'1'; plSet('autoplay',plAutoplay); plRender(); };
|
||||
|
||||
// ── browser back/forward ──────────────────────────────────
|
||||
window.addEventListener('popstate', function(e){ if(e.state&&e.state.url) plTransitionTo(e.state.url, false); });
|
||||
|
||||
// ── init ──────────────────────────────────────────────────
|
||||
function plInit(){
|
||||
var v=document.getElementById('videoPlayer');
|
||||
if(v&&plLoop==='one') v.loop=true;
|
||||
@@ -694,26 +800,164 @@
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
{{-- Up Next header + autoplay toggle --}}
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||||
<h3 style="font-size:16px; font-weight:500; margin:0;">
|
||||
<i class="bi bi-collection-play" style="margin-right:6px;"></i>Up Next
|
||||
</h3>
|
||||
<div class="pl-controls-bar" style="margin-bottom:0; flex-shrink:0;">
|
||||
<button class="pl-ctrl-btn pl-ctrl-autoplay" id="recAutoplayBtn"
|
||||
title="Autoplay" onclick="recToggleAutoplay()">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<span class="pl-autoplay-label">Autoplay</span>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;">
|
||||
<h3 style="font-size:16px; font-weight:500; margin:0;">Up Next</h3>
|
||||
<button id="recAutoplayBtn" class="pl-ctrl-btn pl-ctrl-autoplay" title="Autoplay" onclick="recToggleAutoplay()" style="margin-left:auto;">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<span class="pl-autoplay-label">Autoplay</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var recTransiting = false;
|
||||
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') !== '0';
|
||||
|
||||
// persists across sidebar swaps so already-autoplayed videos are not repeated
|
||||
window._recWatched = window._recWatched || new Set();
|
||||
|
||||
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||
|
||||
function recNextUnwatched() {
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recRender() {
|
||||
var btn = document.getElementById('recAutoplayBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||
// highlight the first unwatched card as "next up"
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||
var nextUp = recNextUnwatched() || cards[0];
|
||||
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
|
||||
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
|
||||
}
|
||||
|
||||
async function recTransitionTo(url, pushHist) {
|
||||
if (recTransiting) return;
|
||||
recTransiting = true;
|
||||
if (window._spaScrollToVideo) window._spaScrollToVideo();
|
||||
try {
|
||||
var dataUrl = url.split('?')[0] + '/player-data';
|
||||
var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!resp.ok) { window.location.href = url; return; }
|
||||
var d = await resp.json();
|
||||
|
||||
// Different player type? Hard-nav so the correct view loads.
|
||||
if (d.type && d.type !== 'generic') { window.location.href = url; return; }
|
||||
|
||||
// preserve volume/muted across source swap
|
||||
var vid = document.getElementById('videoPlayer');
|
||||
var _savedVol = vid ? vid.volume : null;
|
||||
var _savedMuted = vid ? vid.muted : null;
|
||||
|
||||
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
|
||||
|
||||
if (vid && _savedVol !== null) { vid.volume = _savedVol; vid.muted = _savedMuted; }
|
||||
|
||||
// reset progress bar
|
||||
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
|
||||
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
|
||||
if(pl) pl.style.width='0%'; if(sc) sc.style.left='0%';
|
||||
if(cu) cu.textContent='0:00'; if(dr&&d.duration) dr.textContent=recFmt(d.duration);
|
||||
|
||||
// update title
|
||||
var ts = document.getElementById('videoTitleText');
|
||||
if (ts) { ts.textContent = d.title; ts.dataset.primaryTitle = d.title; }
|
||||
document.title = d.title + ' | {{ config("app.name") }}';
|
||||
|
||||
if (pushHist !== false) history.pushState({ url: url }, '', url);
|
||||
|
||||
recSwapContent(url);
|
||||
} catch(e) {
|
||||
console.warn('recTransitionTo', e);
|
||||
window.location.href = url;
|
||||
} finally {
|
||||
recTransiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recSwapContent(url) {
|
||||
try {
|
||||
var resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (!resp.ok) return;
|
||||
var html = await resp.text();
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
var newVdb = doc.getElementById('vdbWrap');
|
||||
var oldVdb = document.getElementById('vdbWrap');
|
||||
if (newVdb && oldVdb) { oldVdb.innerHTML = newVdb.innerHTML; requestAnimationFrame(function(){ if(window._vdbCheckOverflow)_vdbCheckOverflow(); }); }
|
||||
|
||||
var newCh = doc.querySelector('.channel-row');
|
||||
var oldCh = document.querySelector('.channel-row');
|
||||
if (newCh && oldCh) oldCh.outerHTML = newCh.outerHTML;
|
||||
|
||||
var newYtc = doc.getElementById('ytcSection');
|
||||
var oldYtc = document.getElementById('ytcSection');
|
||||
if (newYtc && oldYtc) {
|
||||
oldYtc.innerHTML = newYtc.innerHTML;
|
||||
var s = oldYtc.querySelector('script');
|
||||
if (s) { var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns); }
|
||||
}
|
||||
|
||||
var newSidebar = doc.querySelector('.yt-sidebar-container');
|
||||
var oldSidebar = document.querySelector('.yt-sidebar-container');
|
||||
if (newSidebar && oldSidebar) {
|
||||
oldSidebar.innerHTML = newSidebar.innerHTML;
|
||||
oldSidebar.querySelectorAll('script').forEach(function(s){
|
||||
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
|
||||
});
|
||||
}
|
||||
} catch(e) { console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
// user manually picks a video — reset watch history so next autoplay starts fresh
|
||||
window.recGoTo = function(url) {
|
||||
if (!url) return;
|
||||
window._recWatched = new Set();
|
||||
recTransitionTo(url);
|
||||
};
|
||||
window.recToggleAutoplay = function() {
|
||||
recAutoplay = !recAutoplay;
|
||||
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
|
||||
recRender();
|
||||
};
|
||||
|
||||
// autoplay: mark current video watched, then play first unwatched rec card
|
||||
window._plOnVideoEnd = function() {
|
||||
if (!recAutoplay) return;
|
||||
window._recWatched.add(window.location.href);
|
||||
var next = recNextUnwatched();
|
||||
if (next) {
|
||||
recTransitionTo(next.dataset.recUrl);
|
||||
} else {
|
||||
// all recommendations watched — notify and restart the cycle
|
||||
showToast('All recommendations watched — starting over', 'info');
|
||||
window._recWatched = new Set();
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function(e) { if (e.state && e.state.url) recTransitionTo(e.state.url, false); });
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', recRender); else recRender();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list" id="recList">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
data-rec-url="{{ route('videos.show', $recVideo) }}"
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')">
|
||||
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')"
|
||||
style="cursor:pointer;">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
|
||||
@@ -752,151 +996,6 @@
|
||||
@else
|
||||
<div class="text-secondary" id="recList">No recommendations available yet. Check back later!</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var recTransiting = false;
|
||||
var recAutoplay = localStorage.getItem('ytpAutoplay_solo') === '1';
|
||||
|
||||
// persists across sidebar swaps so already-autoplayed videos are not repeated
|
||||
window._recWatched = window._recWatched || new Set();
|
||||
|
||||
function recFmt(s){ var m=Math.floor(s/60),ss=Math.floor(s%60); return m+':'+(ss<10?'0':'')+ss; }
|
||||
|
||||
function recNextUnwatched() {
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card[data-rec-url]');
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
if (!window._recWatched.has(cards[i].dataset.recUrl)) return cards[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recRender() {
|
||||
var btn = document.getElementById('recAutoplayBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('pl-ctrl-active', recAutoplay);
|
||||
btn.title = 'Autoplay: ' + (recAutoplay ? 'On' : 'Off');
|
||||
// highlight the first unwatched card as "next up"
|
||||
var cards = document.querySelectorAll('#recList .sidebar-video-card');
|
||||
var nextUp = recNextUnwatched() || cards[0];
|
||||
cards.forEach(function(c){ c.style.opacity = ''; c.classList.remove('rec-next-up'); });
|
||||
if (recAutoplay && nextUp) { nextUp.style.opacity = '1'; nextUp.classList.add('rec-next-up'); }
|
||||
}
|
||||
|
||||
window.recToggleAutoplay = function() {
|
||||
recAutoplay = !recAutoplay;
|
||||
localStorage.setItem('ytpAutoplay_solo', recAutoplay ? '1' : '0');
|
||||
recRender();
|
||||
};
|
||||
|
||||
async function recTransitionTo(url, pushHist) {
|
||||
if (!url || recTransiting) return;
|
||||
recTransiting = true;
|
||||
if (window._spaScrollToVideo) window._spaScrollToVideo();
|
||||
try {
|
||||
var m = url.match(/\/videos\/([^/?#]+)/);
|
||||
if (!m) { window.location.href = url; return; }
|
||||
var resp = await fetch('/videos/' + m[1] + '/player-data');
|
||||
if (!resp.ok) { window.location.href = url; return; }
|
||||
var d = await resp.json();
|
||||
|
||||
// load new video source
|
||||
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
|
||||
|
||||
// reset progress bar
|
||||
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
|
||||
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
|
||||
if(pl) pl.style.width='0%'; if(sc) sc.style.left='0%';
|
||||
if(cu) cu.textContent='0:00'; if(dr&&d.duration) dr.textContent=recFmt(d.duration);
|
||||
|
||||
// update title
|
||||
var ts = document.querySelector('.video-title span');
|
||||
if (ts) ts.textContent = d.title;
|
||||
document.title = d.title + ' | {{ config("app.name") }}';
|
||||
|
||||
if (pushHist !== false) history.pushState({recUrl: url}, '', url);
|
||||
|
||||
// async: swap description + comments + sidebar
|
||||
recSwapContent(url);
|
||||
} catch(e) {
|
||||
console.warn('recTransitionTo', e);
|
||||
window.location.href = url;
|
||||
} finally {
|
||||
recTransiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recSwapContent(url) {
|
||||
try {
|
||||
var resp = await fetch(url, {headers:{'Accept':'text/html','X-Requested-With':'XMLHttpRequest'}});
|
||||
var html = await resp.text();
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
// description
|
||||
var nv=doc.getElementById('vdbWrap'),ov=document.getElementById('vdbWrap');
|
||||
if(nv&&ov){ov.innerHTML=nv.innerHTML;if(window._vdbCheckOverflow)_vdbCheckOverflow();}
|
||||
|
||||
// channel row
|
||||
var nc=doc.querySelector('.channel-row'),oc=document.querySelector('.channel-row');
|
||||
if(nc&&oc) oc.innerHTML=nc.innerHTML;
|
||||
|
||||
// comments
|
||||
var ny=doc.getElementById('ytcSection'),oy=document.getElementById('ytcSection');
|
||||
if(ny&&oy){
|
||||
oy.innerHTML=ny.innerHTML;
|
||||
var ytcScript=Array.from(doc.querySelectorAll('script')).find(function(s){return s.textContent.includes('const YTC =');});
|
||||
if(ytcScript){
|
||||
var ns=document.createElement('script');
|
||||
ns.textContent=ytcScript.textContent;
|
||||
document.body.appendChild(ns);
|
||||
document.body.removeChild(ns);
|
||||
}
|
||||
}
|
||||
|
||||
// sidebar: swap inner content and re-run init scripts
|
||||
var nSide=doc.querySelector('.yt-sidebar-container');
|
||||
var oSide=document.querySelector('.yt-sidebar-container');
|
||||
if(nSide&&oSide){
|
||||
oSide.innerHTML=nSide.innerHTML;
|
||||
// re-execute any inline scripts (rec init, playlist init, etc.)
|
||||
Array.from(oSide.querySelectorAll('script')).forEach(function(s){
|
||||
var ns=document.createElement('script');
|
||||
ns.textContent=s.textContent;
|
||||
document.body.appendChild(ns);
|
||||
document.body.removeChild(ns);
|
||||
});
|
||||
}
|
||||
} catch(e){ console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
// user manually picks a video — reset watch history so next autoplay starts fresh
|
||||
window.recGoTo = function(url) {
|
||||
if (!url) return;
|
||||
window._recWatched = new Set();
|
||||
recTransitionTo(url);
|
||||
};
|
||||
|
||||
// autoplay: mark current video watched, then play first unwatched rec card
|
||||
window._plOnVideoEnd = function() {
|
||||
if (!recAutoplay) return;
|
||||
window._recWatched.add(window.location.href);
|
||||
var next = recNextUnwatched();
|
||||
if (next) {
|
||||
recTransitionTo(next.dataset.recUrl);
|
||||
} else {
|
||||
// all recommendations watched — notify and restart the cycle
|
||||
showToast('All recommendations watched — starting over', 'info');
|
||||
window._recWatched = new Set();
|
||||
var first = document.querySelector('#recList .sidebar-video-card[data-rec-url]');
|
||||
if (first) recTransitionTo(first.dataset.recUrl);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function(e){ if(e.state&&e.state.recUrl) recTransitionTo(e.state.recUrl,false); });
|
||||
|
||||
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',recRender); else recRender();
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2612,6 +2612,9 @@
|
||||
try { d=await resp.json(); }
|
||||
catch(e){ window.location.href=url; return; }
|
||||
|
||||
// Different player type? Hard-nav so the correct view loads.
|
||||
if (d.type && d.type !== 'match') { window.location.href = url; return; }
|
||||
|
||||
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
|
||||
catch(e){ console.warn('_ytpLoadSource', e); }
|
||||
|
||||
@@ -2873,6 +2876,9 @@
|
||||
if (!resp.ok) { window.location.href = url; return; }
|
||||
var d = await resp.json();
|
||||
|
||||
// Different player type? Hard-nav so the correct view loads.
|
||||
if (d.type && d.type !== 'match') { window.location.href = url; return; }
|
||||
|
||||
if (window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
|
||||
|
||||
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
|
||||
|
||||
@@ -572,6 +572,9 @@
|
||||
try { d = await resp.json(); }
|
||||
catch(e) { window.location.href=url; return; }
|
||||
|
||||
// Different player type? Hard-nav so the correct view (generic/match) loads.
|
||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||
|
||||
// swap audio src (keeps browser autoplay permission)
|
||||
var audio = document.getElementById('audioEl');
|
||||
if (audio) {
|
||||
@@ -631,6 +634,9 @@
|
||||
document.body.removeChild(ns);
|
||||
}
|
||||
}
|
||||
// The swapped content re-renders video-actions (incl. the
|
||||
// owner lyrics button) fresh+hidden — re-apply its state.
|
||||
if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
|
||||
} catch(e){ console.warn('plSwapContent',e); }
|
||||
}
|
||||
|
||||
@@ -796,6 +802,9 @@
|
||||
if (!resp.ok) { window.location.href = url; return; }
|
||||
var d = await resp.json();
|
||||
|
||||
// Different player type? Hard-nav so the correct view loads.
|
||||
if (d.type && d.type !== 'music') { window.location.href = url; return; }
|
||||
|
||||
var audio = document.getElementById('audioEl');
|
||||
if (audio) {
|
||||
var _savedVol = audio.volume; var _savedMuted = audio.muted;
|
||||
@@ -850,6 +859,8 @@
|
||||
var ns = document.createElement('script'); ns.textContent = s.textContent; s.replaceWith(ns);
|
||||
});
|
||||
}
|
||||
// Re-apply the owner lyrics button state after the swap re-renders it.
|
||||
if(window._lyricsShow) window._lyricsShow(window._ytpTrackId||0);
|
||||
} catch(e) { console.warn('recSwapContent', e); }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user