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 <noreply@anthropic.com>
1969 lines
99 KiB
PHP
1969 lines
99 KiB
PHP
@php
|
|
$audioUrl = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
|
|
$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;
|
|
|
|
// 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();
|
|
$primaryFlag = $allLangData[$primaryLang]['flag'] ?? null;
|
|
$allAudioTracks = collect([[
|
|
'id' => 0,
|
|
'language' => $primaryLang,
|
|
'label' => $video->language ? strtoupper($video->language) : 'Default',
|
|
'name' => $video->language ? ($allLangData[$primaryLang]['name'] ?? strtoupper($video->language)) : 'Default',
|
|
'flag' => $primaryFlag,
|
|
'stream_url' => $audioUrl,
|
|
'title' => $video->title,
|
|
'description' => $video->description ?? '',
|
|
'dl_url' => route('videos.downloadMp3', $video),
|
|
]])->concat($video->audioTracks->map(fn($t) => [
|
|
'id' => $t->id,
|
|
'language' => $t->language,
|
|
'label' => $t->label,
|
|
'name' => $allLangData[$t->language]['name'] ?? strtoupper($t->language),
|
|
'flag' => $allLangData[$t->language]['flag'] ?? null,
|
|
'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp,
|
|
'title' => $t->title ?? '',
|
|
'description' => $t->description ?? '',
|
|
'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">
|
|
<div class="ytp audio-ytp" id="audioContainer" tabindex="0">
|
|
|
|
{{-- Cover art / slideshow — both always in DOM so SPA transitions can switch between them --}}
|
|
<img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg"@if(count($slideUrls) > 1) style="display:none"@endif>
|
|
<div class="slideshow-wrap" id="slideshowWrap"@if(count($slideUrls) <= 1) style="display:none"@endif>
|
|
<img src="{{ $slideUrls[0] ?? $coverUrl }}" alt="" class="slide-img slide-a" id="slideA">
|
|
<img src="{{ count($slideUrls) > 1 ? $slideUrls[1] : ($slideUrls[0] ?? $coverUrl) }}" alt="" class="slide-img slide-b" id="slideB">
|
|
</div>
|
|
|
|
{{-- 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>
|
|
|
|
{{-- Large play overlay --}}
|
|
<div class="ytp-large-play-btn" id="ytpLargePlay">
|
|
<i class="bi bi-play-fill"></i>
|
|
</div>
|
|
|
|
{{-- Controls --}}
|
|
<div class="ytp-chrome-bottom" id="ytpControls">
|
|
|
|
<div class="ytp-progress-bar-container" id="ytpProgressContainer">
|
|
<div class="ytp-progress-bar" id="ytpProgressBar">
|
|
<div class="ytp-play-progress" id="ytpPlayed"></div>
|
|
<div class="ytp-scrubber-container">
|
|
<div class="ytp-scrubber-button" id="ytpScrubber"></div>
|
|
</div>
|
|
<div class="ytp-hover-time" id="ytpHoverTime"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ytp-chrome-controls">
|
|
|
|
<div class="ytp-left-controls">
|
|
|
|
<button class="ytp-button ytp-play-btn" id="ytpPlayBtn" title="Play (k)">
|
|
<svg class="ytp-svg-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
</button>
|
|
|
|
@if(isset($playlist) && $playlist)
|
|
<button class="ytp-button ytp-prev-btn" title="Previous" onclick="if(window.plPrev)window.plPrev();">
|
|
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
|
|
</button>
|
|
<button class="ytp-button ytp-next-btn" title="Next" onclick="if(window.plNext)window.plNext();">
|
|
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
|
</button>
|
|
@endif
|
|
|
|
<div class="ytp-volume-area">
|
|
<button class="ytp-button ytp-mute-btn" id="ytpMuteBtn" title="Mute (m)">
|
|
<svg class="ytp-svg-vol3" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
|
<svg class="ytp-svg-vol0" viewBox="0 0 24 24" style="display:none"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"/></svg>
|
|
</button>
|
|
<div class="ytp-volume-slider-wrap">
|
|
<input type="range" class="ytp-volume-range" id="ytpVolume" min="0" max="100" step="1" value="50">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ytp-time-display">
|
|
<span id="ytpCurrent">0:00</span>
|
|
<span class="ytp-time-sep"> / </span>
|
|
<span id="ytpDuration">0:00</span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="ytp-right-controls">
|
|
|
|
{{-- Language flag — always in DOM; hidden only when no language/flag is set --}}
|
|
<div class="ytp-lang-wrap" id="ytpLangWrap"@if(!$primaryFlag) style="display:none"@endif>
|
|
<button class="ytp-button ytp-lang-btn" id="ytpLangBtn" title="Language">
|
|
@if($primaryFlag)
|
|
<span class="fi fi-{{ $primaryFlag }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>
|
|
@else
|
|
<svg viewBox="0 0 24 24" style="width:22px;height:22px;fill:#fff;"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.9 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2s.07-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.66-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>
|
|
@endif
|
|
</button>
|
|
<div class="ytp-lang-popup" id="ytpLangPopup">
|
|
<div class="ytp-lang-popup-hdr">Language</div>
|
|
@foreach($allAudioTracks as $track)
|
|
<div class="ytp-lang-option {{ $loop->first ? 'active' : '' }}"
|
|
data-lang-id="{{ $track['id'] }}"
|
|
data-lang-url="{{ $track['stream_url'] }}"
|
|
data-lang-label="{{ $track['label'] }}"
|
|
data-lang-name="{{ $track['name'] }}"
|
|
data-lang-flag="{{ $track['flag'] ?? '' }}"
|
|
data-lang-title="{{ $track['title'] ?? '' }}"
|
|
data-lang-description="{{ $track['description'] ?? '' }}"
|
|
data-lang-dl-url="{{ $track['dl_url'] }}">
|
|
<span class="fi fi-{{ $track['flag'] ?: 'xx' }}" style="width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;"></span>
|
|
<span class="ytp-lang-opt-label">{{ $track['name'] }}</span>
|
|
<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ytp-settings-wrap" id="ytpSettingsWrap">
|
|
<button class="ytp-button ytp-settings-btn" id="ytpSettingsBtn" title="Settings">
|
|
<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">
|
|
<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>
|
|
<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>
|
|
Playback speed
|
|
</div>
|
|
@foreach([['0.25','0.25'],['0.5','0.5'],['0.75','0.75'],['1','Normal'],['1.25','1.25'],['1.5','1.5'],['1.75','1.75'],['2','2']] as [$val,$label])
|
|
<div class="ytp-speed-option {{ $val === '1' ? 'active' : '' }}" data-speed="{{ $val }}">
|
|
<svg class="ytp-speed-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
{{ $label }}
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Loop — standalone button, outside gear --}}
|
|
<button class="ytp-button ytp-loop-btn" id="ytpLoopBtn" title="Loop">
|
|
<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">
|
|
<rect x="2" y="14" width="4" height="8" rx="1" fill="white"/>
|
|
<rect x="8" y="8" width="4" height="14" rx="1" fill="white"/>
|
|
<rect x="14" y="11" width="4" height="11" rx="1" fill="white"/>
|
|
<rect x="20" y="5" width="2" height="17" rx="1" fill="white"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<button class="ytp-button ytp-fs-btn" id="ytpFsBtn" title="Full screen (f)">
|
|
<svg class="ytp-svg-fs-enter" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
|
<svg class="ytp-svg-fs-exit" viewBox="0 0 24 24" style="display:none"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 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;
|
|
inset: 0; width: 100%; height: 100%;
|
|
object-fit: contain; display: block;
|
|
}
|
|
|
|
/* Slideshow */
|
|
.slideshow-wrap { position: absolute; inset: 0; background: #000; }
|
|
.slide-img {
|
|
position: absolute; inset: 0; width: 100%; height: 100%;
|
|
object-fit: contain; display: block;
|
|
transition: opacity 1s ease-in-out;
|
|
}
|
|
.slide-a { opacity: 1; z-index: 1; }
|
|
.slide-b { opacity: 0; z-index: 0; }
|
|
|
|
.audio-ytp .ytp-gradient-bottom,
|
|
.audio-ytp .ytp-chrome-bottom,
|
|
.audio-ytp .ytp-large-play-btn { z-index: 4; }
|
|
|
|
/* Bars button: dim when off, red when on */
|
|
.audio-bars-btn svg { opacity: .45; transition: opacity .15s; }
|
|
.audio-bars-btn.bars-on svg { opacity: 1; fill: #f00 !important; }
|
|
.audio-bars-btn.bars-on rect { fill: #f00 !important; }
|
|
|
|
/* ══ Full ytp styles ══ */
|
|
.ytp-wrap {
|
|
position: relative; width: 100% !important; background: #000;
|
|
border-radius: 12px; overflow: hidden;
|
|
aspect-ratio: 16/9 !important;
|
|
height: auto !important;
|
|
max-height: none !important;
|
|
min-height: 0 !important;
|
|
}
|
|
.ytp, .audio-ytp {
|
|
position: relative; width: 100%; height: 100%;
|
|
aspect-ratio: 16/9;
|
|
background: #000; outline: none;
|
|
user-select: none; overflow: hidden;
|
|
font-family: Roboto, Arial, sans-serif;
|
|
}
|
|
.ytp:focus { outline: none; }
|
|
.ytp-gradient-bottom {
|
|
position: absolute; bottom: 0; left: 0; right: 0;
|
|
height: 98px;
|
|
background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,.75));
|
|
pointer-events: none; transition: opacity .25s;
|
|
}
|
|
.ytp-large-play-btn {
|
|
position: absolute; inset: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
pointer-events: none; opacity: 0; transition: opacity .2s;
|
|
}
|
|
.ytp-large-play-btn i { font-size: 72px; color: rgba(255,255,255,.9); text-shadow: 0 0 30px rgba(0,0,0,.6); }
|
|
.ytp-large-play-btn.visible { opacity: 1; }
|
|
|
|
.ytp-chrome-bottom {
|
|
position: absolute; bottom: 0; left: 0; right: 0;
|
|
padding: 0 12px 8px;
|
|
transition: opacity .25s, transform .25s;
|
|
}
|
|
.ytp.controls-hidden .ytp-chrome-bottom { opacity: 0; transform: translateY(4px); pointer-events: none; }
|
|
.ytp.controls-hidden .ytp-gradient-bottom { opacity: 0; }
|
|
|
|
.ytp-progress-bar-container { padding: 4px 0; cursor: pointer; margin-bottom: 4px; }
|
|
.ytp-progress-bar {
|
|
position: relative; height: 3px;
|
|
background: rgba(255,255,255,.2); border-radius: 2px; transition: height .1s;
|
|
}
|
|
.ytp-progress-bar-container:hover .ytp-progress-bar,
|
|
.ytp-progress-bar.dragging { height: 5px; }
|
|
.ytp-play-progress {
|
|
position: absolute; top: 0; left: 0; bottom: 0;
|
|
background: #f00; border-radius: 2px; width: 0; pointer-events: none;
|
|
}
|
|
.ytp-scrubber-container {
|
|
position: absolute; top: 50%; transform: translateY(-50%);
|
|
width: 0; pointer-events: none;
|
|
}
|
|
.ytp-scrubber-button {
|
|
width: 13px; height: 13px; border-radius: 50%; background: #f00;
|
|
transform: translate(-50%, 0) scale(0); transition: transform .1s; margin-top: -4px;
|
|
}
|
|
.ytp-progress-bar-container:hover .ytp-scrubber-button,
|
|
.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); }
|
|
.ytp-hover-time {
|
|
position: absolute; bottom: 16px;
|
|
background: rgba(28,28,28,.9); color: #fff;
|
|
font-size: 12px; padding: 3px 6px; border-radius: 4px;
|
|
pointer-events: none; opacity: 0;
|
|
transform: translateX(-50%); white-space: nowrap; transition: opacity .1s;
|
|
}
|
|
.ytp-progress-bar-container:hover .ytp-hover-time { opacity: 1; }
|
|
|
|
.ytp-chrome-controls {
|
|
display: flex; align-items: center; justify-content: space-between; height: 36px;
|
|
}
|
|
.ytp-left-controls, .ytp-right-controls { display: flex; align-items: center; gap: 4px; }
|
|
|
|
.ytp-button {
|
|
background: none; border: none; color: #fff; cursor: pointer;
|
|
padding: 0; width: 36px; height: 36px;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
border-radius: 50%; transition: background .15s; flex-shrink: 0;
|
|
}
|
|
.ytp-button:hover { background: rgba(255,255,255,.1); }
|
|
.ytp-button svg { width: 22px; height: 22px; fill: #fff; pointer-events: none; }
|
|
.ytp-button:focus { outline: none; }
|
|
.ytp-play-btn svg { width: 26px; height: 26px; }
|
|
|
|
.ytp-volume-area { display: flex; align-items: center; }
|
|
.ytp-volume-slider-wrap {
|
|
overflow: hidden; width: 0; transition: width .2s;
|
|
display: flex; align-items: center;
|
|
}
|
|
.ytp-volume-area:hover .ytp-volume-slider-wrap,
|
|
.ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; }
|
|
.ytp-volume-range {
|
|
-webkit-appearance: none; appearance: none;
|
|
width: 52px; height: 3px; border-radius: 2px;
|
|
background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%));
|
|
outline: none; cursor: pointer; margin: 0 4px;
|
|
}
|
|
.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; }
|
|
.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; }
|
|
|
|
.ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; }
|
|
.ytp-time-sep { opacity: .6; margin: 0 2px; }
|
|
|
|
/* ── Standalone language button ──────────────────────────────────── */
|
|
.ytp-lang-wrap { position: relative; }
|
|
.ytp-lang-btn { display: flex; align-items: center; justify-content: center; opacity: .85; transition: opacity .2s; }
|
|
.ytp-lang-btn:hover { opacity: 1; }
|
|
.ytp-lang-popup {
|
|
display: none; position: absolute; bottom: 48px; right: 0;
|
|
background: rgba(28,28,28,.97); border-radius: 12px;
|
|
min-width: 180px; overflow: hidden;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100;
|
|
}
|
|
.ytp-lang-popup.open { display: block; }
|
|
.ytp-lang-popup-hdr {
|
|
padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px;
|
|
text-transform: uppercase; color: rgba(255,255,255,.45);
|
|
border-bottom: 1px solid rgba(255,255,255,.1);
|
|
}
|
|
.ytp-lang-option {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; color: #fff; font-size: 13px;
|
|
cursor: pointer; transition: background .15s;
|
|
}
|
|
.ytp-lang-option:hover { background: rgba(255,255,255,.1); }
|
|
.ytp-lang-opt-label { flex: 1; }
|
|
.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;
|
|
background: rgba(28,28,28,.95); border-radius: 12px;
|
|
min-width: 200px; overflow: hidden;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,.6); z-index: 100;
|
|
}
|
|
.ytp-settings-panel.open { display: block; }
|
|
.ytp-settings-item {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 12px 16px; color: #fff; font-size: 13px;
|
|
cursor: pointer; transition: background .15s; white-space: nowrap;
|
|
}
|
|
.ytp-settings-item:hover { background: rgba(255,255,255,.1); }
|
|
.ytp-settings-item svg { width: 20px; height: 20px; fill: #fff; flex-shrink: 0; }
|
|
.ytp-settings-item .ytp-settings-val { margin-left: auto; color: rgba(255,255,255,.7); font-size: 12px; margin-right: 4px; }
|
|
.ytp-chevron { width: 18px; height: 18px; flex-shrink: 0; }
|
|
|
|
.ytp-speed-panel { display: none; }
|
|
.ytp-speed-panel.open { display: block; }
|
|
.ytp-speed-back {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 12px 16px; color: #fff; font-size: 13px; font-weight: 600;
|
|
cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.15);
|
|
}
|
|
.ytp-speed-back:hover { background: rgba(255,255,255,.1); }
|
|
.ytp-speed-back svg { width: 20px; height: 20px; fill: #fff; }
|
|
.ytp-speed-option {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 10px 16px; color: #fff; font-size: 13px;
|
|
cursor: pointer; transition: background .15s;
|
|
}
|
|
.ytp-speed-option:hover { background: rgba(255,255,255,.1); }
|
|
.ytp-speed-check { width: 18px; height: 18px; fill: #fff; opacity: 0; flex-shrink: 0; }
|
|
.ytp-speed-option.active .ytp-speed-check { opacity: 1; }
|
|
|
|
.ytp-wrap.ytp-fullscreen {
|
|
position: fixed !important; inset: 0; z-index: 99999;
|
|
max-height: 100vh; height: 100vh; width: 100vw;
|
|
border-radius: 0; aspect-ratio: unset; margin: 0 !important;
|
|
}
|
|
|
|
.ytp-toggle-row { border-top: 1px solid rgba(255,255,255,.08); }
|
|
.ytp-toggle-row svg { opacity: .75; }
|
|
.ytp-toggle-val { font-size: 11px !important; opacity: .6; transition: color .15s, opacity .15s; }
|
|
.ytp-toggle-row.is-on .ytp-toggle-val { color: #f00; opacity: 1; }
|
|
.ytp-toggle-row.is-on svg { fill: #f00; opacity: 1; }
|
|
.ytp-toggle-switch {
|
|
width: 28px; height: 16px; background: rgba(255,255,255,.2);
|
|
border-radius: 8px; position: relative; flex-shrink: 0;
|
|
transition: background .2s; margin-left: 6px;
|
|
}
|
|
.ytp-toggle-row.is-on .ytp-toggle-switch { background: #e61e1e; }
|
|
.ytp-toggle-thumb {
|
|
position: absolute; top: 2px; left: 2px;
|
|
width: 12px; height: 12px; background: #fff;
|
|
border-radius: 50%; transition: transform .2s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
}
|
|
.ytp-toggle-row.is-on .ytp-toggle-thumb { transform: translateX(12px); }
|
|
|
|
/* Standalone loop button */
|
|
.ytp-loop-btn svg { opacity: .75; transition: opacity .15s, fill .15s; }
|
|
.ytp-loop-btn:hover svg { opacity: 1; }
|
|
.ytp-loop-btn.is-on svg { fill: #e61e1e; opacity: 1; }
|
|
|
|
@media (max-width: 576px) {
|
|
.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 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>
|
|
|
|
{{-- ══ JS ══ --}}
|
|
<script>
|
|
(function () {
|
|
|
|
const wrap = document.getElementById('ytpWrap');
|
|
const player = document.getElementById('audioContainer');
|
|
const audio = document.getElementById('audioEl');
|
|
const playBtn = document.getElementById('ytpPlayBtn');
|
|
const muteBtn = document.getElementById('ytpMuteBtn');
|
|
const volRange = document.getElementById('ytpVolume');
|
|
const timeCur = document.getElementById('ytpCurrent');
|
|
const timeDur = document.getElementById('ytpDuration');
|
|
const progCont = document.getElementById('ytpProgressContainer');
|
|
const progBar = document.getElementById('ytpProgressBar');
|
|
const played = document.getElementById('ytpPlayed');
|
|
const scrubber = document.getElementById('ytpScrubber');
|
|
const hoverTime = document.getElementById('ytpHoverTime');
|
|
const largePlay = document.getElementById('ytpLargePlay');
|
|
const settingsBtn = document.getElementById('ytpSettingsBtn');
|
|
const settingsPanel = document.getElementById('ytpSettingsPanel');
|
|
const speedRow = document.getElementById('ytpSpeedRow');
|
|
const speedPanel = document.getElementById('ytpSpeedPanel');
|
|
const speedBack = document.getElementById('ytpSpeedBack');
|
|
const speedLabel = document.getElementById('ytpSpeedLabel');
|
|
const speedOpts = document.querySelectorAll('.ytp-speed-option');
|
|
const fsBtn = document.getElementById('ytpFsBtn');
|
|
const loopBtn = document.getElementById('ytpLoopBtn');
|
|
const barsBtn = document.getElementById('ytpBarsBtn');
|
|
const animCanvas = document.getElementById('audioAnimCanvas');
|
|
|
|
const NEXT_URL = @json($nextUrl ?? null);
|
|
window._LANG_NAMES = window._LANG_NAMES || @json(collect(\App\Data\Languages::all())->map(fn($l) => $l['name']));
|
|
let hideTimer = null;
|
|
let isDragging = false;
|
|
let userSeeking = false;
|
|
let wasPlayingBeforeSeek = false;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
function fmt(s) {
|
|
if (!isFinite(s) || isNaN(s)) return '0:00';
|
|
s = Math.floor(s);
|
|
const m = Math.floor(s / 60), sec = s % 60;
|
|
return m + ':' + String(sec).padStart(2, '0');
|
|
}
|
|
|
|
function updatePlayIcon() {
|
|
playBtn.querySelector('.ytp-svg-play').style.display = audio.paused ? '' : 'none';
|
|
playBtn.querySelector('.ytp-svg-pause').style.display = audio.paused ? 'none' : '';
|
|
player.classList.toggle('playing', !audio.paused);
|
|
}
|
|
|
|
function updateVolumeIcon() {
|
|
const muted = audio.muted || audio.volume === 0;
|
|
muteBtn.querySelector('.ytp-svg-vol3').style.display = muted ? 'none' : '';
|
|
muteBtn.querySelector('.ytp-svg-vol0').style.display = muted ? '' : 'none';
|
|
const v = muted ? 0 : Math.round(audio.volume * 100);
|
|
volRange.value = v;
|
|
volRange.style.setProperty('--vol', v + '%');
|
|
}
|
|
|
|
function updateProgress() {
|
|
if (!audio.duration) return;
|
|
const pct = (audio.currentTime / audio.duration) * 100;
|
|
played.style.width = pct + '%';
|
|
scrubber.parentElement.style.left = pct + '%';
|
|
timeCur.textContent = fmt(audio.currentTime);
|
|
}
|
|
|
|
// ── Controls visibility ──────────────────────────────────────
|
|
function showControls() {
|
|
player.classList.remove('controls-hidden');
|
|
clearTimeout(hideTimer);
|
|
if (!audio.paused) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
|
|
}
|
|
|
|
// ── Play / Pause ─────────────────────────────────────────────
|
|
function togglePlay() {
|
|
if (audio.paused) { audio.play(); } else { audio.pause(); }
|
|
}
|
|
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
|
|
player.addEventListener('click', e => {
|
|
if (settingsPanel.contains(e.target) || settingsBtn === e.target) return;
|
|
if (progCont.contains(e.target)) return;
|
|
togglePlay();
|
|
});
|
|
player.addEventListener('mousemove', showControls);
|
|
|
|
// ── Volume ───────────────────────────────────────────────────
|
|
muteBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
audio.muted = !audio.muted;
|
|
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
|
|
updateVolumeIcon();
|
|
localStorage.setItem('ytpMuted', audio.muted ? '1' : '0');
|
|
});
|
|
volRange.addEventListener('input', e => {
|
|
e.stopPropagation();
|
|
const v = parseInt(e.target.value) / 100;
|
|
audio.volume = v;
|
|
audio.muted = v === 0;
|
|
updateVolumeIcon();
|
|
localStorage.setItem('ytpVolume', e.target.value);
|
|
});
|
|
volRange.addEventListener('click', e => e.stopPropagation());
|
|
|
|
// ── Progress bar ─────────────────────────────────────────────
|
|
function seekTo(pct) {
|
|
if (!audio.duration) return;
|
|
if (!userSeeking) wasPlayingBeforeSeek = !audio.paused;
|
|
userSeeking = true;
|
|
audio.currentTime = Math.max(0, Math.min(1, pct)) * audio.duration;
|
|
updateProgress();
|
|
}
|
|
function progressPct(clientX) {
|
|
const rect = progBar.getBoundingClientRect();
|
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
}
|
|
|
|
progCont.addEventListener('mousemove', e => {
|
|
hoverTime.textContent = fmt(progressPct(e.clientX) * (audio.duration || 0));
|
|
hoverTime.style.left = (progressPct(e.clientX) * 100) + '%';
|
|
if (isDragging) seekTo(progressPct(e.clientX));
|
|
});
|
|
progCont.addEventListener('mousedown', e => {
|
|
e.preventDefault(); isDragging = true;
|
|
progBar.classList.add('dragging'); seekTo(progressPct(e.clientX));
|
|
});
|
|
document.addEventListener('mousemove', e => { if (isDragging) seekTo(progressPct(e.clientX)); });
|
|
document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); } });
|
|
progCont.addEventListener('touchstart', e => { isDragging = true; progBar.classList.add('dragging'); seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
|
progCont.addEventListener('touchmove', e => { if (isDragging) seekTo(progressPct(e.touches[0].clientX)); }, { passive: true });
|
|
progCont.addEventListener('touchend', e => { e.preventDefault(); isDragging = false; progBar.classList.remove('dragging'); });
|
|
|
|
// ── Settings / Speed ─────────────────────────────────────────
|
|
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 => {
|
|
if (!document.getElementById('ytpSettingsWrap').contains(e.target)) {
|
|
settingsPanel.classList.remove('open');
|
|
speedPanel.classList.remove('open');
|
|
speedRow.style.display = '';
|
|
}
|
|
});
|
|
speedRow.addEventListener('click', e => { e.stopPropagation(); speedRow.style.display = 'none'; speedPanel.classList.add('open'); });
|
|
speedBack.addEventListener('click', e => { e.stopPropagation(); speedPanel.classList.remove('open'); speedRow.style.display = ''; });
|
|
speedOpts.forEach(opt => {
|
|
opt.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
const s = parseFloat(opt.dataset.speed);
|
|
audio.playbackRate = s;
|
|
speedOpts.forEach(o => o.classList.remove('active'));
|
|
opt.classList.add('active');
|
|
speedLabel.textContent = s === 1 ? 'Normal' : s;
|
|
settingsPanel.classList.remove('open');
|
|
speedPanel.classList.remove('open');
|
|
speedRow.style.display = '';
|
|
});
|
|
});
|
|
|
|
// ── Language track switching ──────────────────────────────────
|
|
const langBtn = document.getElementById('ytpLangBtn');
|
|
const langPopup = document.getElementById('ytpLangPopup');
|
|
const langOpts = document.querySelectorAll('.ytp-lang-option');
|
|
|
|
// Which version is currently playing (0 = primary). Download + Share read this so they
|
|
// act on exactly the version the viewer chose.
|
|
window._ytpTrackId = 0;
|
|
|
|
if (langBtn && langPopup) {
|
|
langBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
// Only open popup when there are multiple language options
|
|
if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return;
|
|
settingsPanel.classList.remove('open');
|
|
langPopup.classList.toggle('open');
|
|
});
|
|
document.addEventListener('click', () => langPopup.classList.remove('open'));
|
|
langPopup.addEventListener('click', e => e.stopPropagation());
|
|
|
|
langOpts.forEach(opt => {
|
|
opt.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
const url = opt.dataset.langUrl;
|
|
const flag = opt.dataset.langFlag;
|
|
if (!url) return;
|
|
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
|
|
const relPos = audio.duration ? audio.currentTime / audio.duration : 0;
|
|
const wasPlaying = !audio.paused;
|
|
const _vol = audio.volume, _muted = audio.muted;
|
|
audio.src = url; audio.load();
|
|
audio.volume = _vol; audio.muted = _muted;
|
|
audio.addEventListener('loadedmetadata', function() {
|
|
if (audio.duration) audio.currentTime = relPos * audio.duration;
|
|
updateProgress();
|
|
if (wasPlaying) audio.play().catch(() => {});
|
|
}, { once: true });
|
|
langOpts.forEach(o => o.classList.remove('active'));
|
|
opt.classList.add('active');
|
|
langBtn.innerHTML = flag
|
|
? `<span class="fi fi-${flag}" style="width:22px;height:16px;border-radius:2px;display:inline-block;"></span>`
|
|
: langBtn.innerHTML;
|
|
langPopup.classList.remove('open');
|
|
|
|
// Update title flag
|
|
const titleFlagEl = document.getElementById('videoTitleFlag');
|
|
if (titleFlagEl && flag) {
|
|
titleFlagEl.className = 'fi fi-' + flag;
|
|
titleFlagEl.style.display = 'inline-block';
|
|
}
|
|
|
|
// Update page title text
|
|
const titleTextEl = document.getElementById('videoTitleText');
|
|
const trackTitle = opt.dataset.langTitle || '';
|
|
const primaryTitle = titleTextEl ? (titleTextEl.dataset.primaryTitle || titleTextEl.textContent) : '';
|
|
const displayTitle = trackTitle || primaryTitle;
|
|
if (titleTextEl) titleTextEl.textContent = displayTitle;
|
|
document.title = displayTitle + ' | {{ config("app.name") }}';
|
|
|
|
// Update description box — fall back to primary track description if track has none
|
|
const primaryOptEl = langPopup.querySelector('.ytp-lang-option[data-lang-id="0"]');
|
|
const primaryDesc = primaryOptEl ? (primaryOptEl.dataset.langDescription || '') : '';
|
|
const desc = opt.dataset.langDescription || primaryDesc;
|
|
_updateDescriptionBox(desc);
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// Shared / deep links: ?track={id} opens directly on that version so the recipient gets
|
|
// the same language end-to-end (audio + title + flag + About description). Deferred to
|
|
// DOMContentLoaded because this player partial is included BEFORE the title/description
|
|
// elements appear in the page — they must exist before the switch handler can update them.
|
|
const _ytpAutoSelectTrack = function () {
|
|
const want = new URLSearchParams(window.location.search).get('track');
|
|
if (!want || want === '0') return;
|
|
const opt = [...langOpts].find(o => o.dataset.langId === String(want));
|
|
if (opt && !opt.classList.contains('active')) opt.click();
|
|
};
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', _ytpAutoSelectTrack);
|
|
} else {
|
|
_ytpAutoSelectTrack();
|
|
}
|
|
}
|
|
|
|
// ── Description box updater ──────────────────────────────────
|
|
function _rteToHtml(s) {
|
|
s = (s || '').trim();
|
|
if (!s) return '';
|
|
if (/<[a-z][\s\S]*>/i.test(s)) return s; // already sanitized HTML (server-side)
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML.replace(/\n/g, '<br>'); // legacy plain text
|
|
}
|
|
function _updateDescriptionBox(text) {
|
|
const html = _rteToHtml(text);
|
|
const aboutPanel = document.getElementById('vdb-about');
|
|
let shortEl = document.getElementById('vdbDescShort');
|
|
|
|
if (!shortEl && aboutPanel) {
|
|
const ph = aboutPanel.querySelector('p[style*="text-secondary"]');
|
|
if (ph) ph.style.display = 'none';
|
|
shortEl = document.createElement('div');
|
|
shortEl.id = 'vdbDescShort';
|
|
shortEl.className = 'vdb-desc-text vdb-clamp';
|
|
aboutPanel.appendChild(shortEl);
|
|
}
|
|
if (!shortEl) return;
|
|
|
|
// Drop the legacy two-element model if a previous build left it behind.
|
|
const legacyFull = document.getElementById('vdbDescFull');
|
|
if (legacyFull) legacyFull.remove();
|
|
|
|
let moreBtn = aboutPanel ? aboutPanel.querySelector('.vdb-show-more') : null;
|
|
|
|
if (!html) {
|
|
shortEl.style.display = 'none';
|
|
if (moreBtn) moreBtn.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
shortEl.style.display = '';
|
|
shortEl.classList.add('vdb-clamp');
|
|
shortEl.classList.remove('vdb-expanded');
|
|
shortEl.innerHTML = html;
|
|
|
|
if (!moreBtn) {
|
|
moreBtn = document.createElement('button');
|
|
moreBtn.className = 'vdb-show-more';
|
|
moreBtn.onclick = function () { if (window.toggleVdbDesc) toggleVdbDesc(moreBtn); };
|
|
shortEl.insertAdjacentElement('afterend', moreBtn);
|
|
}
|
|
moreBtn.textContent = 'Show more';
|
|
// Reveal "Show more" only when the content overflows the clamp. Compare the
|
|
// natural content height to the clamp's pixel limit (130px, see .vdb-clamp) —
|
|
// clientHeight is unreliable right after innerHTML swap / when re-laying out.
|
|
const _btn = moreBtn, _el = shortEl;
|
|
requestAnimationFrame(function () {
|
|
_btn.style.display = (_el.scrollHeight > 138) ? 'block' : 'none';
|
|
});
|
|
}
|
|
|
|
// ── Fullscreen ───────────────────────────────────────────────
|
|
fsBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
document.fullscreenElement ? document.exitFullscreen() : wrap.requestFullscreen();
|
|
showControls();
|
|
});
|
|
document.addEventListener('fullscreenchange', () => {
|
|
const fs = !!document.fullscreenElement;
|
|
wrap.classList.toggle('ytp-fullscreen', fs);
|
|
fsBtn.querySelector('.ytp-svg-fs-enter').style.display = fs ? 'none' : '';
|
|
fsBtn.querySelector('.ytp-svg-fs-exit').style.display = fs ? '' : 'none';
|
|
if (screen.orientation && screen.orientation.lock) {
|
|
if (fs) screen.orientation.lock('landscape').catch(function(){});
|
|
else screen.orientation.unlock();
|
|
}
|
|
});
|
|
|
|
// ── Keyboard shortcuts ────────────────────────────────────────
|
|
document.addEventListener('keydown', e => {
|
|
const tag = document.activeElement.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement.isContentEditable) return;
|
|
switch (e.key) {
|
|
case ' ': case 'k': case 'K': e.preventDefault(); togglePlay(); showControls(); break;
|
|
case 'm': case 'M':
|
|
e.preventDefault();
|
|
audio.muted = !audio.muted;
|
|
if (!audio.muted && audio.volume === 0) audio.volume = 0.5;
|
|
updateVolumeIcon(); showControls(); break;
|
|
case 'f': case 'F': e.preventDefault(); fsBtn.click(); break;
|
|
case 'ArrowLeft': e.preventDefault(); if (audio.duration) audio.currentTime = Math.max(0, audio.currentTime - 5); showControls(); break;
|
|
case 'ArrowRight': e.preventDefault(); if (audio.duration) audio.currentTime = Math.min(audio.duration, audio.currentTime + 5); showControls(); break;
|
|
case 'ArrowUp': e.preventDefault(); audio.volume = Math.min(1, audio.volume + 0.05); audio.muted = false; updateVolumeIcon(); showControls(); break;
|
|
case 'ArrowDown': e.preventDefault(); audio.volume = Math.max(0, audio.volume - 0.05); updateVolumeIcon(); showControls(); break;
|
|
default:
|
|
if (e.key >= '0' && e.key <= '9') {
|
|
e.preventDefault();
|
|
if (audio.duration) audio.currentTime = (parseInt(e.key) / 10) * audio.duration;
|
|
showControls();
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Audio events ─────────────────────────────────────────────
|
|
audio.addEventListener('play', () => { updatePlayIcon(); startBars(); showControls(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
|
audio.addEventListener('pause', () => {
|
|
if (userSeeking) return; // suppress mid-seek pause events
|
|
updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock();
|
|
});
|
|
audio.addEventListener('seeked', () => {
|
|
userSeeking = false;
|
|
if (wasPlayingBeforeSeek) {
|
|
if (audio.paused) audio.play().catch(() => {});
|
|
largePlay.classList.remove('visible');
|
|
}
|
|
});
|
|
audio.addEventListener('timeupdate', updateProgress);
|
|
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); updateProgress(); });
|
|
audio.addEventListener('ended', () => { releaseWakeLock(); if (window._plOnTrackEnd) { window._plOnTrackEnd(); } else if (NEXT_URL) { window.location.href = NEXT_URL; } });
|
|
audio.addEventListener('volumechange', updateVolumeIcon);
|
|
|
|
// ── Loop ─────────────────────────────────────────────────────
|
|
let isLooping = localStorage.getItem('ytpLoop') === '1';
|
|
audio.loop = isLooping;
|
|
function applyLoopState() {
|
|
if (!loopBtn) return;
|
|
loopBtn.classList.toggle('is-on', isLooping);
|
|
loopBtn.title = isLooping ? 'Loop: On' : 'Loop';
|
|
}
|
|
applyLoopState();
|
|
if (loopBtn) {
|
|
loopBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
isLooping = !isLooping;
|
|
audio.loop = isLooping;
|
|
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
|
|
applyLoopState();
|
|
showControls();
|
|
});
|
|
}
|
|
|
|
// ── Wake Lock ─────────────────────────────────────────────────
|
|
let wakeLock = null;
|
|
async function requestWakeLock() {
|
|
if (!('wakeLock' in navigator) || wakeLock) return;
|
|
try { wakeLock = await navigator.wakeLock.request('screen'); } catch(e) {}
|
|
}
|
|
function releaseWakeLock() {
|
|
if (wakeLock) { wakeLock.release(); wakeLock = null; }
|
|
}
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && !audio.paused) requestWakeLock();
|
|
});
|
|
|
|
// ── Bars visualiser ───────────────────────────────────────────
|
|
let audioCtx = null, analyser = null, dataArray = null, rafId = null;
|
|
let barsOn = localStorage.getItem('audioBarsOn') === '1';
|
|
|
|
// Extract dominant colors from cover art for bar gradients
|
|
let imgColors = ['rgb(255,255,255)', 'rgb(200,200,200)', 'rgb(170,170,170)'];
|
|
|
|
function colorWithAlpha(col, a) {
|
|
return col.replace('rgb(', 'rgba(').replace(')', ',' + a + ')');
|
|
}
|
|
|
|
function extractColors(img) {
|
|
try {
|
|
const cv = document.createElement('canvas');
|
|
cv.width = cv.height = 24;
|
|
const cx = cv.getContext('2d');
|
|
cx.drawImage(img, 0, 0, 24, 24);
|
|
const px = cx.getImageData(0, 0, 24, 24).data;
|
|
const buckets = {};
|
|
for (let i = 0; i < px.length; i += 4) {
|
|
const r = px[i], g = px[i+1], b = px[i+2];
|
|
const bright = (r + g + b) / 3;
|
|
if (bright < 25 || bright > 230) continue;
|
|
const max = Math.max(r,g,b), min = Math.min(r,g,b);
|
|
if (max === 0 || (max - min) / max < 0.25) continue;
|
|
const key = `${r>>2},${g>>2},${b>>2}`;
|
|
buckets[key] = (buckets[key] || { r:0, g:0, b:0, n:0 });
|
|
buckets[key].r += r; buckets[key].g += g; buckets[key].b += b; buckets[key].n++;
|
|
}
|
|
const sorted = Object.values(buckets).sort((a,b) => b.n - a.n);
|
|
if (!sorted.length) return;
|
|
const chosen = [sorted[0]];
|
|
for (let i = 1; i < sorted.length && chosen.length < 3; i++) {
|
|
const c = sorted[i];
|
|
const far = chosen.every(e => {
|
|
const dr = e.r/e.n - c.r/c.n, dg = e.g/e.n - c.g/c.n, db = e.b/e.n - c.b/c.n;
|
|
return Math.sqrt(dr*dr + dg*dg + db*db) > 60;
|
|
});
|
|
if (far) chosen.push(c);
|
|
}
|
|
imgColors = chosen.map(c => `rgb(${Math.round(c.r/c.n)},${Math.round(c.g/c.n)},${Math.round(c.b/c.n)})`);
|
|
while (imgColors.length < 3) imgColors.push(imgColors[0]);
|
|
} catch(e) {}
|
|
}
|
|
|
|
const coverImg = document.getElementById('audioCoverImg');
|
|
if (coverImg) {
|
|
if (coverImg.complete && coverImg.naturalWidth) {
|
|
extractColors(coverImg);
|
|
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
|
|
} else {
|
|
coverImg.addEventListener('load', () => {
|
|
extractColors(coverImg);
|
|
if (coverImg.naturalHeight > coverImg.naturalWidth) coverImg.style.objectFit = 'contain';
|
|
}, { once: true });
|
|
}
|
|
}
|
|
|
|
function applyBarsState() {
|
|
barsBtn.classList.toggle('bars-on', barsOn);
|
|
barsBtn.title = barsOn ? 'Visualiser: On' : 'Visualiser: Off';
|
|
animCanvas.style.display = barsOn ? 'block' : 'none';
|
|
}
|
|
applyBarsState();
|
|
|
|
barsBtn.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
barsOn = !barsOn;
|
|
localStorage.setItem('audioBarsOn', barsOn ? '1' : '0');
|
|
applyBarsState();
|
|
if (barsOn && !audio.paused) startBars(); else stopBars();
|
|
showControls();
|
|
});
|
|
|
|
function initAnalyser() {
|
|
if (audioCtx) { audioCtx.resume(); return; }
|
|
try {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const src = audioCtx.createMediaElementSource(audio);
|
|
analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
analyser.smoothingTimeConstant = 0.75;
|
|
src.connect(analyser);
|
|
analyser.connect(audioCtx.destination);
|
|
dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
} catch (e) { console.warn('Web Audio unavailable:', e); }
|
|
}
|
|
|
|
function drawBars() {
|
|
if (!analyser) return;
|
|
analyser.getByteFrequencyData(dataArray);
|
|
const ctx = animCanvas.getContext('2d');
|
|
const w = animCanvas.offsetWidth;
|
|
const h = animCanvas.offsetHeight;
|
|
if (animCanvas.width !== w) animCanvas.width = w;
|
|
if (animCanvas.height !== h) animCanvas.height = h;
|
|
ctx.clearRect(0, 0, w, h);
|
|
const bins = 48;
|
|
const barW = (w / bins) * 0.7;
|
|
const gap = (w / bins) * 0.3;
|
|
const maxH = h * 0.25;
|
|
for (let i = 0; i < bins; i++) {
|
|
const val = dataArray[i + 2] / 255;
|
|
const barH = Math.max(3, val * maxH);
|
|
const x = i * (barW + gap) + gap / 2;
|
|
const y = h - barH;
|
|
const col = imgColors[Math.floor((i / bins) * imgColors.length)];
|
|
const grad = ctx.createLinearGradient(0, y, 0, h);
|
|
grad.addColorStop(0, colorWithAlpha(col, (0.5 + val * 0.5).toFixed(2)));
|
|
grad.addColorStop(1, colorWithAlpha(col, 0.12));
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, barW, barH, [3, 3, 0, 0]);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function barsLoop() {
|
|
rafId = requestAnimationFrame(barsLoop);
|
|
drawBars();
|
|
}
|
|
|
|
function startBars() {
|
|
if (!barsOn) return;
|
|
initAnalyser();
|
|
if (!rafId) rafId = requestAnimationFrame(barsLoop);
|
|
}
|
|
function stopBars() {
|
|
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
if (animCanvas.getContext) animCanvas.getContext('2d').clearRect(0, 0, animCanvas.width, animCanvas.height);
|
|
}
|
|
|
|
// ── Crossfade slideshow ───────────────────────────────────────
|
|
// 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;
|
|
let aIsTop = true;
|
|
let slideshowTimer = null;
|
|
const slideOrientations = new Array(Math.max(SLIDE_URLS.length, 1)).fill(false);
|
|
|
|
function applyOrientation(el, idx) {
|
|
if (!el) return;
|
|
el.classList.toggle('portrait', !!slideOrientations[idx]);
|
|
}
|
|
function getSlideInterval() {
|
|
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
|
|
}
|
|
function advanceSlide() {
|
|
if (!slideA || !slideB || SLIDE_URLS.length <= 1) return;
|
|
currentSlide = (currentSlide + 1) % SLIDE_URLS.length;
|
|
const next = SLIDE_URLS[currentSlide];
|
|
if (aIsTop) {
|
|
slideB.src = next; applyOrientation(slideB, currentSlide);
|
|
slideB.style.zIndex = '2'; slideB.style.opacity = '1';
|
|
slideA.style.opacity = '0'; slideA.style.zIndex = '1';
|
|
} else {
|
|
slideA.src = next; applyOrientation(slideA, currentSlide);
|
|
slideA.style.zIndex = '2'; slideA.style.opacity = '1';
|
|
slideB.style.opacity = '0'; slideB.style.zIndex = '1';
|
|
}
|
|
aIsTop = !aIsTop;
|
|
}
|
|
function startSlideshow() {
|
|
if (slideshowTimer || SLIDE_URLS.length <= 1) return;
|
|
slideshowTimer = setInterval(advanceSlide, getSlideInterval());
|
|
}
|
|
function stopSlideshow() {
|
|
clearInterval(slideshowTimer);
|
|
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);
|
|
audio.addEventListener('ended', stopSlideshow);
|
|
|
|
audio.addEventListener('seeked', () => {
|
|
if (SLIDE_URLS.length <= 1 || !slideA || !slideB || !audio.duration) return;
|
|
stopSlideshow();
|
|
const idx = Math.min(Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length), SLIDE_URLS.length - 1);
|
|
currentSlide = idx;
|
|
slideA.style.transition = 'none'; slideB.style.transition = 'none';
|
|
slideA.src = SLIDE_URLS[idx]; applyOrientation(slideA, idx);
|
|
slideA.style.opacity = '1'; slideA.style.zIndex = '2';
|
|
slideB.style.opacity = '0'; slideB.style.zIndex = '1';
|
|
const nextIdx = (idx + 1) % SLIDE_URLS.length;
|
|
slideB.src = SLIDE_URLS[nextIdx]; applyOrientation(slideB, nextIdx);
|
|
aIsTop = true;
|
|
requestAnimationFrame(() => { slideA.style.transition = ''; slideB.style.transition = ''; });
|
|
if (!audio.paused) startSlideshow();
|
|
});
|
|
|
|
// Preload orientations
|
|
SLIDE_URLS.forEach((url, idx) => {
|
|
const img = new Image();
|
|
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
|
|
img.src = url;
|
|
});
|
|
|
|
if (SLIDE_URLS.length > 1 && slideA) {
|
|
function initSlideA() {
|
|
extractColors(slideA);
|
|
const portrait = slideA.naturalHeight > slideA.naturalWidth;
|
|
slideA.classList.toggle('portrait', portrait);
|
|
slideOrientations[0] = portrait;
|
|
}
|
|
if (slideA.complete && slideA.naturalWidth) initSlideA();
|
|
else slideA.addEventListener('load', initSlideA, { once: true });
|
|
}
|
|
|
|
// ── SPA update hook — called by recTransitionTo / plTransitionTo ──────────
|
|
window._audioPlayerUpdate = function(d) {
|
|
// 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) {
|
|
newSlides.forEach(function(s) { SLIDE_URLS.push(s); });
|
|
if (coverEl) coverEl.style.display = 'none';
|
|
if (slideshowEl) slideshowEl.style.display = '';
|
|
currentSlide = 0; aIsTop = true;
|
|
if (slideA) { slideA.style.transition='none'; slideA.src=newSlides[0]; slideA.style.opacity='1'; slideA.style.zIndex='2'; }
|
|
if (slideB) { slideB.style.transition='none'; slideB.src=newSlides[1]||newSlides[0]; slideB.style.opacity='0'; slideB.style.zIndex='1'; }
|
|
requestAnimationFrame(function() { if(slideA) slideA.style.transition=''; if(slideB) slideB.style.transition=''; });
|
|
} else {
|
|
if (slideshowEl) slideshowEl.style.display = 'none';
|
|
if (coverEl) { coverEl.style.display = ''; coverEl.src = d.cover_url || ''; }
|
|
}
|
|
|
|
// Rebuild language track popup from player-data response
|
|
(function() {
|
|
var langWrap = document.getElementById('ytpLangWrap');
|
|
var langPopup = document.getElementById('ytpLangPopup');
|
|
if (!langWrap || !langPopup || !langBtn) return;
|
|
|
|
// New song loaded → back to its primary track, so shares don't carry a stale track id.
|
|
window._ytpTrackId = 0;
|
|
|
|
function flagHtml(flag, size) {
|
|
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
|
|
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
|
|
return '<span class="fi fi-' + (flag || 'xx') + '" style="' + s + '"></span>';
|
|
}
|
|
function langName(code) {
|
|
if (!code) return 'Default';
|
|
return (window._LANG_NAMES && window._LANG_NAMES[String(code).toLowerCase()]) || String(code).toUpperCase();
|
|
}
|
|
|
|
// Build combined track list: primary + deduped extras
|
|
var primaryLangNew = d.language || null;
|
|
var primaryFlagFromApi = d.language_flag || null;
|
|
var allTracks = [];
|
|
if (primaryLangNew) {
|
|
allTracks.push({
|
|
id: 0,
|
|
language: primaryLangNew,
|
|
label: primaryLangNew.toUpperCase(),
|
|
flag: primaryFlagFromApi,
|
|
stream_url: d.stream_url,
|
|
title: d.title || '',
|
|
description: d.description || '',
|
|
dl_url: d.download_url || d.stream_url
|
|
});
|
|
} else {
|
|
allTracks.push({ id: 0, language: '', label: 'Default', flag: null, stream_url: d.stream_url, title: d.title || '', description: d.description || '', dl_url: d.download_url || d.stream_url });
|
|
}
|
|
(d.audio_tracks || []).forEach(function(t) {
|
|
allTracks.push({
|
|
id: t.id,
|
|
language: t.language,
|
|
label: t.label,
|
|
flag: t.flag,
|
|
stream_url: t.stream_url,
|
|
title: t.title || '',
|
|
description: t.description || '',
|
|
dl_url: t.dl_url || (t.stream_url + '?download=1')
|
|
});
|
|
});
|
|
|
|
var hasMultiple = allTracks.length > 1;
|
|
var primaryFlagVal = allTracks[0] ? allTracks[0].flag : null;
|
|
// Show the button whenever the primary language has a flag; hide only when no flag at all
|
|
langWrap.style.display = primaryFlagVal ? '' : 'none';
|
|
langBtn.innerHTML = flagHtml(primaryFlagVal, 'lg');
|
|
|
|
if (!hasMultiple) {
|
|
// Single track — flag is read-only language indicator, no popup needed
|
|
langPopup.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// Rebuild popup HTML
|
|
var html = '<div class="ytp-lang-popup-hdr">Language</div>';
|
|
allTracks.forEach(function(t, i) {
|
|
var safeDesc = (t.description || '').replace(/"/g, '"');
|
|
var safeTitle = (t.title || '').replace(/"/g, '"');
|
|
html += '<div class="ytp-lang-option' + (i === 0 ? ' active' : '') + '"'
|
|
+ ' data-lang-id="' + t.id + '"'
|
|
+ ' data-lang-url="' + t.stream_url + '"'
|
|
+ ' data-lang-label="' + t.label + '"'
|
|
+ ' data-lang-name="' + langName(t.language) + '"'
|
|
+ ' data-lang-flag="' + (t.flag || '') + '"'
|
|
+ ' data-lang-title="' + safeTitle + '"'
|
|
+ ' data-lang-description="' + safeDesc + '"'
|
|
+ ' data-lang-dl-url="' + (t.dl_url || t.stream_url) + '">'
|
|
+ flagHtml(t.flag, 'sm')
|
|
+ '<span class="ytp-lang-opt-label">' + langName(t.language) + '</span>'
|
|
+ '<svg class="ytp-lang-check" viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>'
|
|
+ '</div>';
|
|
});
|
|
langPopup.innerHTML = html;
|
|
|
|
// Bind click handlers on rebuilt options
|
|
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(opt) {
|
|
opt.addEventListener('click', function() {
|
|
var url = opt.dataset.langUrl;
|
|
var flag = opt.dataset.langFlag;
|
|
var curTime = audio.currentTime;
|
|
// Track the selected version so "Share" links to this exact language (0 = primary).
|
|
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
|
|
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
|
|
opt.classList.add('active');
|
|
langPopup.classList.remove('open');
|
|
var _vol = audio.volume, _muted = audio.muted;
|
|
audio.src = url; audio.load();
|
|
audio.volume = _vol; audio.muted = _muted;
|
|
audio.addEventListener('loadedmetadata', function() {
|
|
audio.currentTime = curTime; audio.play().catch(function(){});
|
|
}, { once: true });
|
|
langBtn.innerHTML = flagHtml(flag, 'lg');
|
|
// Update title flag
|
|
var tfe = document.getElementById('videoTitleFlag');
|
|
if (tfe && flag) { tfe.className = 'fi fi-' + flag; tfe.style.display = 'inline-block'; }
|
|
// Update page title text
|
|
var tte2 = document.getElementById('videoTitleText');
|
|
var tTitle2 = opt.dataset.langTitle || '';
|
|
var pTitle2 = tte2 ? (tte2.dataset.primaryTitle || tte2.textContent) : '';
|
|
var dTitle2 = tTitle2 || pTitle2;
|
|
if (tte2) tte2.textContent = dTitle2;
|
|
document.title = dTitle2 + ' | {{ config("app.name") }}';
|
|
// Update description — fall back to primary track description if track has none
|
|
var primaryDescFallback = allTracks[0] ? (allTracks[0].description || '') : '';
|
|
_updateDescriptionBox(opt.dataset.langDescription || primaryDescFallback);
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
// Reset button to primary track flag
|
|
langBtn.innerHTML = flagHtml(allTracks[0] ? allTracks[0].flag : null, 'lg');
|
|
}());
|
|
|
|
// Update page title + title flag
|
|
var titleEl = document.getElementById('videoTitleText') || document.querySelector('.video-title span:not(#videoTitleFlag)');
|
|
if (titleEl) { titleEl.textContent = d.title || ''; titleEl.dataset.primaryTitle = d.title || ''; }
|
|
document.title = (d.title || '') + ' | {{ config("app.name") }}';
|
|
var titleFlagEl2 = document.getElementById('videoTitleFlag');
|
|
if (titleFlagEl2) {
|
|
var pf = d.language_flag || null;
|
|
if (pf) { titleFlagEl2.className = 'fi fi-' + pf; titleFlagEl2.style.display = 'inline-block'; }
|
|
else { titleFlagEl2.style.display = 'none'; }
|
|
}
|
|
// Update description to primary track's description
|
|
_updateDescriptionBox(d.description || '');
|
|
|
|
// Reset progress bar
|
|
if (played) played.style.width = '0%';
|
|
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 ─────────────────────────────────────────────────────
|
|
const savedVol = localStorage.getItem('ytpVolume');
|
|
const savedMuted = localStorage.getItem('ytpMuted');
|
|
audio.volume = savedVol ? parseInt(savedVol) / 100 : 0.8;
|
|
audio.muted = true; // start muted so autoplay always works
|
|
updateVolumeIcon();
|
|
largePlay.classList.add('visible');
|
|
showControls();
|
|
|
|
// Once playing, restore user's mute preference
|
|
audio.addEventListener('playing', function restoreSound() {
|
|
audio.removeEventListener('playing', restoreSound);
|
|
if (savedMuted !== '1') {
|
|
audio.muted = false;
|
|
updateVolumeIcon();
|
|
}
|
|
}, { once: true });
|
|
|
|
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;
|
|
audio.play().catch(() => {});
|
|
});
|
|
});
|
|
|
|
// ── 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>
|