ghassan 0b2e95ea65 Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00

844 lines
38 KiB
PHP

@php
$audioUrl = route('videos.stream', $video);
$coverUrl = $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => asset('storage/thumbnails/' . $s->filename))->values()->all()
: [];
@endphp
<div class="ytp-wrap" id="ytpWrap">
<div class="ytp audio-ytp" id="audioContainer" tabindex="0">
{{-- Cover art / slideshow --}}
@if(count($slideUrls) > 1)
<div class="slideshow-wrap" id="slideshowWrap">
<img src="{{ $slideUrls[0] }}" alt="" class="slide-img slide-a" id="slideA">
<img src="{{ $slideUrls[1] }}" alt="" class="slide-img slide-b" id="slideB">
</div>
@else
<img src="{{ $coverUrl }}" alt="{{ $video->title }}" class="audio-cover-img" id="audioCoverImg">
@endif
{{-- Bars canvas overlay --}}
<canvas id="audioAnimCanvas" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;display:none;z-index:3;"></canvas>
{{-- 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($previousVideo) && $prevUrl)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
@endif
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<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">
<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>
<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>
{{-- Loop toggle --}}
<div class="ytp-settings-item ytp-toggle-row" id="ytpLoopRow">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
<span>Loop</span>
<span class="ytp-settings-val ytp-toggle-val" id="ytpLoopVal">Off</span>
<div class="ytp-toggle-switch"><div class="ytp-toggle-thumb"></div></div>
</div>
</div>
</div>
{{-- 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>
{{-- ══ CSS ══ --}}
<style>
.audio-ytp { cursor: default; }
.audio-cover-img {
position: absolute;
inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
}
/* Slideshow */
.slideshow-wrap { position: absolute; inset: 0; background: #000; }
.slide-img {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block;
transition: opacity 1s ease-in-out;
}
.slide-img.portrait { object-fit: contain; }
.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%; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9; max-height: 70vh;
}
.ytp {
position: relative; width: 100%; height: 100%;
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; }
.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); }
@media (max-width: 576px) {
.ytp-wrap { border-radius: 0; max-height: 56vw; }
.ytp-button { width: 32px; height: 32px; }
.ytp-button svg { width: 18px; height: 18px; }
.ytp-time-display { font-size: 11px; padding: 0 4px; }
}
</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 loopRow = document.getElementById('ytpLoopRow');
const loopVal = document.getElementById('ytpLoopVal');
const barsBtn = document.getElementById('ytpBarsBtn');
const animCanvas = document.getElementById('audioAnimCanvas');
const NEXT_URL = @json($nextUrl ?? null);
let hideTimer = null;
let isDragging = 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;
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;
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', () => { 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 = ''; }
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 = '';
});
});
// ── 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';
});
// ── 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', () => { updatePlayIcon(); stopBars(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock(); });
audio.addEventListener('timeupdate', updateProgress);
audio.addEventListener('durationchange', () => { timeDur.textContent = fmt(audio.duration); });
audio.addEventListener('ended', () => { releaseWakeLock(); 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 (!loopRow) return;
loopRow.classList.toggle('is-on', isLooping);
if (loopVal) loopVal.textContent = isLooping ? 'On' : 'Off';
}
applyLoopState();
if (loopRow) {
loopRow.addEventListener('click', e => {
e.stopPropagation();
isLooping = !isLooping;
audio.loop = isLooping;
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
applyLoopState();
});
}
// ── 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 ───────────────────────────────────────
const SLIDE_URLS = @json($slideUrls);
if (SLIDE_URLS.length > 1) {
const slideA = document.getElementById('slideA');
const slideB = document.getElementById('slideB');
let currentSlide = 0;
let aIsTop = true; // slideA starts on top (opacity 1)
// Preload all images and detect portrait orientation
const slideOrientations = new Array(SLIDE_URLS.length).fill(false); // true = portrait
SLIDE_URLS.forEach((url, idx) => {
const img = new Image();
img.onload = () => { slideOrientations[idx] = img.naturalHeight > img.naturalWidth; };
img.src = url;
});
function applyOrientation(el, idx) {
el.classList.toggle('portrait', !!slideOrientations[idx]);
}
function getSlideInterval() {
return audio.duration ? (audio.duration / SLIDE_URLS.length) * 1000 : 8000;
}
function advanceSlide() {
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;
}
let slideshowTimer = null;
function startSlideshow() {
if (slideshowTimer || SLIDE_URLS.length <= 1) return;
slideshowTimer = setInterval(advanceSlide, getSlideInterval());
}
function stopSlideshow() {
clearInterval(slideshowTimer);
slideshowTimer = null;
}
audio.addEventListener('play', startSlideshow);
audio.addEventListener('pause', stopSlideshow);
audio.addEventListener('ended', stopSlideshow);
// Seek: jump to the correct slide instantly (no transition flash)
audio.addEventListener('seeked', () => {
if (!audio.duration) return;
stopSlideshow();
const idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * SLIDE_URLS.length),
SLIDE_URLS.length - 1
);
currentSlide = idx;
// Reset: show correct slide without animation
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();
});
// Extract colors + apply orientation for first slide
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 });
}
}
// ── 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);
const p = audio.play();
if (p) p.catch(() => {
audio.muted = true;
audio.play().catch(() => {});
});
});
})();
</script>