ghassan 4887d0c517 Lock screen to landscape on fullscreen, unlock on exit
On mobile, entering fullscreen now also locks the screen orientation to
landscape via the Screen Orientation API. Exiting fullscreen unlocks it,
allowing the device to return to portrait. Applied to both the video
player and audio player. Gracefully ignored on browsers that don't
support screen.orientation.lock (e.g. iOS Safari).

Also includes the playlist auto-scroll fix (committed separately).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:14:28 +03:00

872 lines
40 KiB
PHP

@php
$audioUrl = route('videos.stream', $video);
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
@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>
{{-- 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;
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 = ''; }
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';
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); });
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 (!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 ───────────────────────────────────────
// Variables hoisted outside the if-block so the SPA update hook can access them
const SLIDE_URLS = @json($slideUrls);
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;
}
// 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) {
var newSlides = (d.slides && d.slides.length > 1) ? d.slides : [];
var coverEl = document.getElementById('audioCoverImg');
var slideshowEl = document.getElementById('slideshowWrap');
stopSlideshow();
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 || ''; }
}
// Update page title
var titleEl = document.querySelector('.video-title span');
if (titleEl) titleEl.textContent = d.title || '';
document.title = (d.title || '') + ' | {{ config("app.name") }}';
// 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);
};
// ── 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>