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>
1364 lines
52 KiB
PHP
1364 lines
52 KiB
PHP
@props([
|
||
'video',
|
||
'nextVideo' => null,
|
||
'previousVideo' => null,
|
||
'playlist' => null,
|
||
'playlistVideos' => null,
|
||
])
|
||
|
||
@php
|
||
$orientationClass = match($video->orientation ?? 'landscape') {
|
||
'portrait' => 'portrait',
|
||
'square' => 'square',
|
||
'ultrawide' => 'ultrawide',
|
||
default => '',
|
||
};
|
||
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
|
||
$mp4Url = route('videos.stream', $video);
|
||
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
|
||
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
|
||
|
||
// Build playlist data for client-side shuffle
|
||
$plData = null;
|
||
if ($playlist && $playlistVideos && $playlistVideos->count() > 0) {
|
||
$plData = [
|
||
'playlistId' => $playlist->share_token,
|
||
'currentVideoId' => $video->id,
|
||
'videos' => $playlistVideos->map(fn($v) => [
|
||
'id' => $v->id,
|
||
'showUrl' => route('videos.show', $v) . '?playlist=' . $playlist->id,
|
||
])->values()->all(),
|
||
];
|
||
}
|
||
@endphp
|
||
|
||
{{-- ══════════════════════════════════════════════
|
||
PLAYER WRAP — theater class toggled by JS
|
||
══════════════════════════════════════════════ --}}
|
||
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap">
|
||
<div class="ytp" id="videoContainer" tabindex="0">
|
||
|
||
{{-- ── Video element ── --}}
|
||
<video id="videoPlayer" playsinline preload="auto" autoplay muted></video>
|
||
|
||
{{-- ── Slot for type-specific overlays (coach notes, etc.) ── --}}
|
||
{{ $overlay ?? '' }}
|
||
|
||
{{-- ── Play/pause ripple (double-tap seek feedback) ── --}}
|
||
<div class="ytp-dbl-left" id="ytpDblLeft"><i class="bi bi-arrow-counterclockwise"></i><span>10</span></div>
|
||
<div class="ytp-dbl-right" id="ytpDblRight"><i class="bi bi-arrow-clockwise"></i><span>10</span></div>
|
||
|
||
{{-- ── Spinner ── --}}
|
||
<div class="ytp-spinner" id="ytpSpinner">
|
||
<div class="ytp-spinner-circle"></div>
|
||
</div>
|
||
|
||
{{-- ── Play overlay (shows when paused, hides when playing) ── --}}
|
||
<div class="ytp-large-play-btn" id="ytpLargePlay">
|
||
<i class="bi bi-play-fill"></i>
|
||
</div>
|
||
|
||
{{-- ── Gradient fade at bottom ── --}}
|
||
<div class="ytp-gradient-bottom"></div>
|
||
|
||
{{-- ════════════════════════════════════════
|
||
CONTROLS
|
||
════════════════════════════════════════ --}}
|
||
<div class="ytp-chrome-bottom" id="ytpControls">
|
||
|
||
{{-- Progress bar --}}
|
||
<div class="ytp-progress-bar-container" id="ytpProgressContainer">
|
||
<div class="ytp-progress-bar" id="ytpProgressBar">
|
||
<div class="ytp-load-progress" id="ytpBuffered"></div>
|
||
<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>
|
||
|
||
{{-- Chrome bottom row --}}
|
||
<div class="ytp-chrome-controls">
|
||
|
||
{{-- Left --}}
|
||
<div class="ytp-left-controls">
|
||
|
||
{{-- Play / Pause --}}
|
||
<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>
|
||
|
||
{{-- Prev / Next playlist --}}
|
||
@if($previousVideo || ($playlist && $playlistVideos && $playlistVideos->count() > 1))
|
||
<button class="ytp-button" id="ytpPrevBtn" title="Previous"
|
||
onclick="navigatePrev()">
|
||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
|
||
</button>
|
||
@endif
|
||
@if($nextVideo || ($playlist && $playlistVideos && $playlistVideos->count() > 1))
|
||
<button class="ytp-button" id="ytpNextBtn" title="Next (shift+n)"
|
||
onclick="navigateNext()">
|
||
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||
</button>
|
||
@endif
|
||
|
||
{{-- Volume --}}
|
||
<div class="ytp-volume-area" id="ytpVolumeArea">
|
||
<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-vol2" viewBox="0 0 24 24" style="display:none"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
|
||
<svg class="ytp-svg-vol1" viewBox="0 0 24 24" style="display:none"><path d="M7 9v6h4l5 5V4l-5 5H7z"/></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" id="ytpVolumeWrap">
|
||
<input type="range" class="ytp-volume-range" id="ytpVolume"
|
||
min="0" max="100" step="1" value="50">
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Time display --}}
|
||
<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>{{-- /left --}}
|
||
|
||
{{-- Right --}}
|
||
<div class="ytp-right-controls">
|
||
|
||
{{-- Settings --}}
|
||
<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" id="ytpLoopSwitch"><div class="ytp-toggle-thumb"></div></div>
|
||
</div>
|
||
{{-- Autoplay toggle (playlist only) --}}
|
||
@if($playlist)
|
||
<div class="ytp-settings-item ytp-toggle-row" id="ytpAutoplayRow">
|
||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||
<span>Autoplay</span>
|
||
<span class="ytp-settings-val ytp-toggle-val" id="ytpAutoplayVal">On</span>
|
||
<div class="ytp-toggle-switch" id="ytpAutoplaySwitch"><div class="ytp-toggle-thumb"></div></div>
|
||
</div>
|
||
{{-- Shuffle toggle (playlist with >1 video) --}}
|
||
@if($playlistVideos && $playlistVideos->count() > 1)
|
||
<div class="ytp-settings-item ytp-toggle-row" id="ytpShuffleRow">
|
||
<svg viewBox="0 0 24 24"><path d="M10.59 9.17 5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
|
||
<span>Shuffle</span>
|
||
<span class="ytp-settings-val ytp-toggle-val" id="ytpShuffleVal">Off</span>
|
||
<div class="ytp-toggle-switch" id="ytpShuffleSwitch"><div class="ytp-toggle-thumb"></div></div>
|
||
</div>
|
||
@endif
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Picture-in-Picture --}}
|
||
<button class="ytp-button ytp-pip-btn" id="ytpPipBtn" title="Miniplayer (i)">
|
||
<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>
|
||
</button>
|
||
|
||
{{-- Theater mode --}}
|
||
<button class="ytp-button ytp-theater-btn" id="ytpTheaterBtn" title="Theater mode (t)">
|
||
<svg class="ytp-svg-theater-off" viewBox="0 0 24 24"><path d="M19 6H5c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H5V8h14v8z"/></svg>
|
||
<svg class="ytp-svg-theater-on" viewBox="0 0 24 24" style="display:none"><path d="M19 7H5c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 8H5V9h14v6z"/></svg>
|
||
</button>
|
||
|
||
{{-- Fullscreen --}}
|
||
<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>{{-- /right --}}
|
||
</div>{{-- /chrome-controls --}}
|
||
</div>{{-- /chrome-bottom --}}
|
||
|
||
</div>{{-- /ytp --}}
|
||
</div>{{-- /ytp-wrap --}}
|
||
|
||
|
||
@once
|
||
<style>
|
||
/* ══════════════════════════════════════════════════════════
|
||
YTP PLAYER — YouTube-identical custom player
|
||
══════════════════════════════════════════════════════════ */
|
||
.ytp-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
background: #000;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
/* default aspect ratio; overridden per orientation */
|
||
aspect-ratio: 16/9;
|
||
max-height: 70vh;
|
||
}
|
||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-width: 50vh; margin: 0 auto; max-height: unset; }
|
||
.ytp-wrap.square { aspect-ratio: 1/1; max-width: 70vh; margin: 0 auto; max-height: unset; }
|
||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: unset; }
|
||
|
||
/* Theater mode */
|
||
.ytp-wrap.theater {
|
||
max-height: 80vh;
|
||
aspect-ratio: unset;
|
||
height: 80vh;
|
||
border-radius: 0;
|
||
}
|
||
|
||
/* Fullscreen */
|
||
.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;
|
||
}
|
||
|
||
/* ── Inner player ── */
|
||
.ytp {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #000;
|
||
cursor: pointer;
|
||
outline: none;
|
||
user-select: none;
|
||
overflow: hidden;
|
||
font-family: Roboto, Arial, sans-serif;
|
||
}
|
||
.ytp:focus { outline: none; }
|
||
|
||
/* ── Video element ── */
|
||
#videoPlayer {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
background: #000;
|
||
}
|
||
|
||
/* ── Gradient bottom ── */
|
||
.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,.7));
|
||
pointer-events: none;
|
||
transition: opacity .25s;
|
||
}
|
||
|
||
/* ── Large play overlay ── */
|
||
.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; }
|
||
|
||
/* ── Spinner ── */
|
||
.ytp-spinner {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity .2s;
|
||
}
|
||
.ytp-spinner.active { opacity: 1; }
|
||
.ytp-spinner-circle {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 4px solid rgba(255,255,255,.2);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: ytpSpin .8s linear infinite;
|
||
}
|
||
@keyframes ytpSpin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Double-tap feedback ── */
|
||
.ytp-dbl-left, .ytp-dbl-right {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 30%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
color: #fff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity .2s;
|
||
border-radius: 50%;
|
||
}
|
||
.ytp-dbl-left { left: 0; }
|
||
.ytp-dbl-right { right: 0; }
|
||
.ytp-dbl-left i, .ytp-dbl-right i { font-size: 40px; }
|
||
.ytp-dbl-left.show, .ytp-dbl-right.show { opacity: 1; }
|
||
|
||
/* ══ CONTROLS ══ */
|
||
.ytp-chrome-bottom {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 0 12px 8px;
|
||
transition: opacity .25s, transform .25s;
|
||
transform: translateY(0);
|
||
}
|
||
/* hidden state */
|
||
.ytp.controls-hidden .ytp-chrome-bottom { opacity: 0; transform: translateY(4px); pointer-events: none; }
|
||
.ytp.controls-hidden .ytp-gradient-bottom { opacity: 0; }
|
||
|
||
/* ── Progress bar ── */
|
||
.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-load-progress {
|
||
position: absolute;
|
||
top: 0; left: 0; bottom: 0;
|
||
background: rgba(255,255,255,.4);
|
||
border-radius: 2px;
|
||
width: 0;
|
||
pointer-events: none;
|
||
}
|
||
.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; }
|
||
|
||
/* ── Chrome controls row ── */
|
||
.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;
|
||
}
|
||
|
||
/* ── Buttons ── */
|
||
.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; }
|
||
|
||
/* play btn slightly larger icon */
|
||
.ytp-play-btn svg { width: 26px; height: 26px; }
|
||
|
||
/* ── Volume area ── */
|
||
.ytp-volume-area {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
/* ── Time display ── */
|
||
.ytp-time-display {
|
||
font-size: 13px;
|
||
color: #fff;
|
||
white-space: nowrap;
|
||
padding: 0 6px;
|
||
line-height: 36px;
|
||
font-weight: 400;
|
||
}
|
||
.ytp-time-sep { opacity: .6; margin: 0 2px; }
|
||
|
||
/* ── Settings panel ── */
|
||
.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;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid rgba(255,255,255,.15);
|
||
font-weight: 600;
|
||
}
|
||
.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; }
|
||
|
||
/* ── Mobile ── */
|
||
@media (max-width: 768px) {
|
||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
||
.ytp-wrap.portrait { max-height: unset; }
|
||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||
}
|
||
|
||
@media (max-width: 576px) {
|
||
.ytp-button { width: 32px; height: 32px; }
|
||
.ytp-button svg { width: 18px; height: 18px; }
|
||
.ytp-time-display { font-size: 11px; padding: 0 4px; }
|
||
.ytp-pip-btn, .ytp-theater-btn { display: none; }
|
||
}
|
||
|
||
/* Hide PIP if not supported – handled by JS */
|
||
.ytp-pip-btn.unsupported { display: none; }
|
||
|
||
/* Settings panel toggle rows */
|
||
.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; }
|
||
|
||
/* Toggle switch pill */
|
||
.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); }
|
||
|
||
/* ── Theater mode layout (landscape / ultrawide) ── */
|
||
.video-layout-container.theater-mode {
|
||
flex-wrap: wrap;
|
||
}
|
||
.video-layout-container.theater-mode > .ytp-wrap {
|
||
width: 100%;
|
||
flex-shrink: 0;
|
||
}
|
||
.video-layout-container.theater-mode .yt-video-section {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
@media (max-width: 1300px) {
|
||
.video-layout-container.theater-mode > .yt-sidebar-container { width: 300px; }
|
||
}
|
||
@media (min-width: 1301px) {
|
||
.video-layout-container.theater-mode > .yt-sidebar-container { width: 400px; }
|
||
}
|
||
|
||
/* ── Theater mode layout (portrait) — stack everything below ── */
|
||
.video-layout-container.theater-mode-portrait {
|
||
flex-direction: column;
|
||
}
|
||
.video-layout-container.theater-mode-portrait > .ytp-wrap {
|
||
width: 100%;
|
||
flex-shrink: 0;
|
||
}
|
||
.video-layout-container.theater-mode-portrait .yt-video-section,
|
||
.video-layout-container.theater-mode-portrait > .yt-sidebar-container {
|
||
width: 100%;
|
||
}
|
||
</style>
|
||
@endonce
|
||
|
||
@once
|
||
<script>
|
||
/* ══════════════════════════════════════════════════════
|
||
YTP PLAYER ENGINE
|
||
══════════════════════════════════════════════════════ */
|
||
(function () {
|
||
|
||
// ── DOM refs ──────────────────────────────────────────
|
||
const wrap = document.getElementById('ytpWrap');
|
||
const player = document.getElementById('videoContainer');
|
||
const video = document.getElementById('videoPlayer');
|
||
const playBtn = document.getElementById('ytpPlayBtn');
|
||
const muteBtn = document.getElementById('ytpMuteBtn');
|
||
const volRange = document.getElementById('ytpVolume');
|
||
const volWrap = document.getElementById('ytpVolumeWrap');
|
||
const timeCur = document.getElementById('ytpCurrent');
|
||
const timeDur = document.getElementById('ytpDuration');
|
||
const controls = document.getElementById('ytpControls');
|
||
const progCont = document.getElementById('ytpProgressContainer');
|
||
const progBar = document.getElementById('ytpProgressBar');
|
||
const buffered = document.getElementById('ytpBuffered');
|
||
const played = document.getElementById('ytpPlayed');
|
||
const scrubber = document.getElementById('ytpScrubber');
|
||
const hoverTime = document.getElementById('ytpHoverTime');
|
||
const spinner = document.getElementById('ytpSpinner');
|
||
const largePlay = document.getElementById('ytpLargePlay');
|
||
const dblLeft = document.getElementById('ytpDblLeft');
|
||
const dblRight = document.getElementById('ytpDblRight');
|
||
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 pipBtn = document.getElementById('ytpPipBtn');
|
||
const theaterBtn = document.getElementById('ytpTheaterBtn');
|
||
const loopRow = document.getElementById('ytpLoopRow');
|
||
const loopVal = document.getElementById('ytpLoopVal');
|
||
|
||
// ── State ─────────────────────────────────────────────
|
||
let hideTimer = null;
|
||
let isDragging = false;
|
||
let isTheater = false;
|
||
let isFullscreen = false;
|
||
let currentSpeed = 1;
|
||
let lastTap = 0;
|
||
let tapTimer = null;
|
||
let userSeeking = false; // true from seekTo() until 'seeked' fires
|
||
let wasPlayingBeforeSeek = false; // remember play state so we can resume after seek
|
||
|
||
// ── HLS source ────────────────────────────────────────
|
||
const HLS_URL = @json($hlsUrl);
|
||
const MP4_URL = @json($mp4Url);
|
||
const NEXT_URL = @json($nextUrl);
|
||
const PREV_URL = @json($prevUrl);
|
||
const PL_DATA = @json($plData); // null when not in a playlist
|
||
|
||
// ── Playlist shuffle/autoplay state ───────────────────
|
||
const shuffleRow = document.getElementById('ytpShuffleRow');
|
||
const shuffleVal = document.getElementById('ytpShuffleVal');
|
||
const autoplayRow = document.getElementById('ytpAutoplayRow');
|
||
const autoplayVal = document.getElementById('ytpAutoplayVal');
|
||
|
||
const plId = PL_DATA?.playlistId ?? null;
|
||
|
||
let shuffleOn = plId ? localStorage.getItem('ytpShuffleOn_' + plId) === '1' : false;
|
||
let autoplayOn = plId ? localStorage.getItem('ytpAutoplay') !== '0' : false;
|
||
|
||
function applyShuffleState() {
|
||
if (!shuffleRow) return;
|
||
shuffleRow.classList.toggle('is-on', shuffleOn);
|
||
if (shuffleVal) shuffleVal.textContent = shuffleOn ? 'On' : 'Off';
|
||
}
|
||
function applyAutoplayState() {
|
||
if (!autoplayRow) return;
|
||
autoplayRow.classList.toggle('is-on', autoplayOn);
|
||
if (autoplayVal) autoplayVal.textContent = autoplayOn ? 'On' : 'Off';
|
||
}
|
||
|
||
if (shuffleRow) {
|
||
applyShuffleState();
|
||
shuffleRow.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
shuffleOn = !shuffleOn;
|
||
localStorage.setItem('ytpShuffleOn_' + plId, shuffleOn ? '1' : '0');
|
||
if (shuffleOn) buildShuffledOrder();
|
||
else localStorage.removeItem('ytpShuffledOrder_' + plId);
|
||
applyShuffleState();
|
||
});
|
||
}
|
||
if (autoplayRow) {
|
||
applyAutoplayState();
|
||
autoplayRow.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
autoplayOn = !autoplayOn;
|
||
localStorage.setItem('ytpAutoplay', autoplayOn ? '1' : '0');
|
||
applyAutoplayState();
|
||
});
|
||
}
|
||
|
||
function buildShuffledOrder() {
|
||
if (!PL_DATA) return;
|
||
const vids = PL_DATA.videos;
|
||
const curIdx = vids.findIndex(v => v.id === PL_DATA.currentVideoId);
|
||
const others = vids.map((_, i) => i).filter(i => i !== curIdx);
|
||
for (let i = others.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[others[i], others[j]] = [others[j], others[i]];
|
||
}
|
||
const order = [curIdx, ...others];
|
||
localStorage.setItem('ytpShuffledOrder_' + plId, JSON.stringify(order));
|
||
return order;
|
||
}
|
||
|
||
function getShuffledNextUrl() {
|
||
if (!PL_DATA) return NEXT_URL;
|
||
const vids = PL_DATA.videos;
|
||
const curIdx = vids.findIndex(v => v.id === PL_DATA.currentVideoId);
|
||
const stored = localStorage.getItem('ytpShuffledOrder_' + plId);
|
||
const order = stored ? JSON.parse(stored) : buildShuffledOrder();
|
||
const posNow = order.indexOf(curIdx);
|
||
const posNext = (posNow + 1) % order.length;
|
||
// Don't wrap around unless loop-all
|
||
if (!isLooping && posNext === 0) return null;
|
||
return vids[order[posNext]]?.showUrl ?? null;
|
||
}
|
||
function getShuffledPrevUrl() {
|
||
if (!PL_DATA) return PREV_URL;
|
||
const vids = PL_DATA.videos;
|
||
const curIdx = vids.findIndex(v => v.id === PL_DATA.currentVideoId);
|
||
const stored = localStorage.getItem('ytpShuffledOrder_' + plId);
|
||
const order = stored ? JSON.parse(stored) : buildShuffledOrder();
|
||
const posNow = order.indexOf(curIdx);
|
||
const posPrev = (posNow - 1 + order.length) % order.length;
|
||
return vids[order[posPrev]]?.showUrl ?? PREV_URL;
|
||
}
|
||
|
||
window._ytpHls = null;
|
||
|
||
function initSource() {
|
||
video.muted = true;
|
||
video.autoplay = true;
|
||
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
||
window._ytpHls = new Hls({ startLevel: -1 });
|
||
// Register MANIFEST_PARSED before loadSource to avoid cache race condition
|
||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
|
||
video.muted = true;
|
||
video.play().catch(function(){});
|
||
});
|
||
window._ytpHls.loadSource(HLS_URL);
|
||
window._ytpHls.attachMedia(video);
|
||
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
// Native HLS (Safari) — set src then play on loadedmetadata
|
||
video.src = HLS_URL;
|
||
video.load();
|
||
video.addEventListener('loadedmetadata', function() {
|
||
video.muted = true;
|
||
video.play().catch(function(){});
|
||
}, { once: true });
|
||
} else {
|
||
// Plain MP4 — play on loadedmetadata (canplay/MANIFEST_PARSED don't apply)
|
||
video.src = MP4_URL;
|
||
video.load();
|
||
video.addEventListener('loadedmetadata', function() {
|
||
video.muted = true;
|
||
video.play().catch(function(){});
|
||
}, { once: true });
|
||
}
|
||
}
|
||
|
||
// Reinitialize source after SPA transition — called by playlist overlay scripts
|
||
window._ytpLoadSource = function(hlsUrl, mp4Url) {
|
||
if (window._ytpHls) { window._ytpHls.destroy(); window._ytpHls = null; }
|
||
video.muted = true;
|
||
video.autoplay = true;
|
||
if (hlsUrl && window.Hls && Hls.isSupported()) {
|
||
window._ytpHls = new Hls({ startLevel: -1 });
|
||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
});
|
||
window._ytpHls.loadSource(hlsUrl);
|
||
window._ytpHls.attachMedia(video);
|
||
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
video.src = hlsUrl;
|
||
video.load();
|
||
video.addEventListener('loadedmetadata', function() {
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
}, { once: true });
|
||
} else {
|
||
video.src = mp4Url;
|
||
video.load();
|
||
video.addEventListener('loadedmetadata', function() {
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
}, { once: true });
|
||
}
|
||
};
|
||
|
||
// Load HLS.js from CDN then init
|
||
function loadHlsJs(cb) {
|
||
if (window.Hls) { cb(); return; }
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5/dist/hls.min.js';
|
||
s.onload = cb;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────
|
||
function fmt(s) {
|
||
if (!isFinite(s) || isNaN(s)) return '0:00';
|
||
s = Math.floor(s);
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const sec = s % 60;
|
||
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
|
||
return m + ':' + String(sec).padStart(2,'0');
|
||
}
|
||
|
||
function updatePlayIcon() {
|
||
const playSvg = playBtn.querySelector('.ytp-svg-play');
|
||
const pauseSvg = playBtn.querySelector('.ytp-svg-pause');
|
||
if (video.paused) {
|
||
playSvg.style.display = '';
|
||
pauseSvg.style.display = 'none';
|
||
} else {
|
||
playSvg.style.display = 'none';
|
||
pauseSvg.style.display = '';
|
||
}
|
||
}
|
||
|
||
function updateVolumeIcon() {
|
||
const svgs = ['ytp-svg-vol3','ytp-svg-vol2','ytp-svg-vol1','ytp-svg-vol0'];
|
||
const vol = video.volume;
|
||
const muted = video.muted || vol === 0;
|
||
svgs.forEach(c => muteBtn.querySelector('.'+c).style.display = 'none');
|
||
if (muted) muteBtn.querySelector('.ytp-svg-vol0').style.display = '';
|
||
else if (vol > .5) muteBtn.querySelector('.ytp-svg-vol3').style.display = '';
|
||
else if (vol > .1) muteBtn.querySelector('.ytp-svg-vol2').style.display = '';
|
||
else muteBtn.querySelector('.ytp-svg-vol1').style.display = '';
|
||
volRange.value = muted ? 0 : Math.round(vol * 100);
|
||
volRange.style.setProperty('--vol', (muted ? 0 : vol * 100) + '%');
|
||
}
|
||
|
||
function updateProgress() {
|
||
if (!video.duration) return;
|
||
const pct = (video.currentTime / video.duration) * 100;
|
||
played.style.width = pct + '%';
|
||
scrubber.parentElement.style.left = pct + '%';
|
||
timeCur.textContent = fmt(video.currentTime);
|
||
|
||
// buffered
|
||
if (video.buffered.length > 0) {
|
||
const bufEnd = video.buffered.end(video.buffered.length - 1);
|
||
buffered.style.width = ((bufEnd / video.duration) * 100) + '%';
|
||
}
|
||
}
|
||
|
||
// ── Controls visibility ───────────────────────────────
|
||
function showControls() {
|
||
player.classList.remove('controls-hidden');
|
||
resetHideTimer();
|
||
}
|
||
function resetHideTimer() {
|
||
clearTimeout(hideTimer);
|
||
if (!video.paused) {
|
||
hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
|
||
}
|
||
}
|
||
function onActivity() { showControls(); }
|
||
|
||
// ── Play / Pause ──────────────────────────────────────
|
||
function togglePlay() {
|
||
if (video.paused) {
|
||
video.play();
|
||
largePlay.classList.remove('visible');
|
||
} else {
|
||
video.pause();
|
||
largePlay.classList.add('visible');
|
||
}
|
||
}
|
||
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
|
||
|
||
// ── Click on video to play/pause, dbl-click seek/fullscreen ──
|
||
let clickTimer = null;
|
||
player.addEventListener('click', e => {
|
||
if (e.target === settingsBtn || settingsPanel.contains(e.target)) return;
|
||
if (progCont.contains(e.target)) return;
|
||
clearTimeout(clickTimer);
|
||
clickTimer = setTimeout(() => { togglePlay(); }, 200);
|
||
});
|
||
player.addEventListener('dblclick', e => {
|
||
clearTimeout(clickTimer);
|
||
const rect = player.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
if (x < rect.width * .4) {
|
||
seekRelative(-10);
|
||
flashDbl(dblLeft);
|
||
} else if (x > rect.width * .6) {
|
||
seekRelative(10);
|
||
flashDbl(dblRight);
|
||
} else {
|
||
toggleFullscreen();
|
||
}
|
||
});
|
||
|
||
function flashDbl(el) {
|
||
el.classList.add('show');
|
||
setTimeout(() => el.classList.remove('show'), 600);
|
||
}
|
||
|
||
// ── Mouse move / touch on player ─────────────────────
|
||
player.addEventListener('mousemove', onActivity);
|
||
player.addEventListener('touchstart', onActivity, { passive: true });
|
||
|
||
// ── Volume ────────────────────────────────────────────
|
||
muteBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
video.muted = !video.muted;
|
||
if (!video.muted && video.volume === 0) video.volume = 0.2;
|
||
updateVolumeIcon();
|
||
localStorage.setItem('ytpMuted', video.muted ? '1' : '0');
|
||
showControls();
|
||
});
|
||
volRange.addEventListener('input', e => {
|
||
e.stopPropagation();
|
||
const v = parseInt(e.target.value) / 100;
|
||
video.volume = v;
|
||
video.muted = v === 0;
|
||
updateVolumeIcon();
|
||
localStorage.setItem('ytpVolume', e.target.value);
|
||
localStorage.setItem('ytpMuted', v === 0 ? '1' : '0');
|
||
});
|
||
volRange.addEventListener('click', e => e.stopPropagation());
|
||
|
||
// ── Progress bar scrubbing ────────────────────────────
|
||
function seekTo(pct) {
|
||
if (!video.duration) return;
|
||
if (!userSeeking) wasPlayingBeforeSeek = !video.paused;
|
||
userSeeking = true;
|
||
video.currentTime = pct * video.duration;
|
||
updateProgress();
|
||
}
|
||
function progressPct(e) {
|
||
const rect = progBar.getBoundingClientRect();
|
||
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
}
|
||
function touchProgressPct(e) {
|
||
const rect = progBar.getBoundingClientRect();
|
||
return Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));
|
||
}
|
||
|
||
progCont.addEventListener('mousemove', e => {
|
||
const pct = progressPct(e);
|
||
hoverTime.textContent = fmt(pct * (video.duration || 0));
|
||
hoverTime.style.left = (pct * 100) + '%';
|
||
if (isDragging) {
|
||
seekTo(pct);
|
||
played.style.width = (pct * 100) + '%';
|
||
scrubber.parentElement.style.left = (pct * 100) + '%';
|
||
}
|
||
});
|
||
progCont.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
isDragging = true;
|
||
progBar.classList.add('dragging');
|
||
seekTo(progressPct(e));
|
||
});
|
||
document.addEventListener('mouseup', () => {
|
||
if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); }
|
||
});
|
||
document.addEventListener('mousemove', e => {
|
||
if (isDragging) {
|
||
const rect = progBar.getBoundingClientRect();
|
||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
seekTo(pct);
|
||
}
|
||
});
|
||
progCont.addEventListener('touchstart', e => {
|
||
isDragging = true;
|
||
progBar.classList.add('dragging');
|
||
seekTo(touchProgressPct(e));
|
||
}, { passive: true });
|
||
progCont.addEventListener('touchmove', e => {
|
||
if (isDragging) seekTo(touchProgressPct(e));
|
||
}, { passive: true });
|
||
progCont.addEventListener('touchend', e => { e.preventDefault(); isDragging = false; progBar.classList.remove('dragging'); });
|
||
|
||
// ── Settings panel ────────────────────────────────────
|
||
settingsBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const open = settingsPanel.classList.toggle('open');
|
||
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
|
||
showControls();
|
||
clearTimeout(hideTimer); // keep controls visible while settings open
|
||
});
|
||
document.addEventListener('click', e => {
|
||
if (!document.getElementById('ytpSettingsWrap').contains(e.target)) {
|
||
settingsPanel.classList.remove('open');
|
||
speedPanel.classList.remove('open');
|
||
speedRow.style.display = '';
|
||
}
|
||
});
|
||
if (speedRow) {
|
||
speedRow.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
speedRow.style.display = 'none';
|
||
speedPanel.classList.add('open');
|
||
});
|
||
}
|
||
if (speedBack) {
|
||
speedBack.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
speedPanel.classList.remove('open');
|
||
speedRow.style.display = '';
|
||
});
|
||
}
|
||
speedOpts.forEach(opt => {
|
||
opt.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const speed = parseFloat(opt.dataset.speed);
|
||
video.playbackRate = speed;
|
||
currentSpeed = speed;
|
||
speedOpts.forEach(o => o.classList.remove('active'));
|
||
opt.classList.add('active');
|
||
speedLabel.textContent = speed === 1 ? 'Normal' : speed;
|
||
settingsPanel.classList.remove('open');
|
||
speedPanel.classList.remove('open');
|
||
speedRow.style.display = '';
|
||
});
|
||
});
|
||
|
||
// ── Fullscreen ────────────────────────────────────────
|
||
function toggleFullscreen() {
|
||
if (document.fullscreenElement) {
|
||
document.exitFullscreen();
|
||
} else {
|
||
wrap.requestFullscreen && wrap.requestFullscreen();
|
||
}
|
||
}
|
||
fsBtn.addEventListener('click', e => { e.stopPropagation(); toggleFullscreen(); showControls(); });
|
||
document.addEventListener('fullscreenchange', () => {
|
||
isFullscreen = !!document.fullscreenElement;
|
||
wrap.classList.toggle('ytp-fullscreen', isFullscreen);
|
||
const enter = fsBtn.querySelector('.ytp-svg-fs-enter');
|
||
const exit = fsBtn.querySelector('.ytp-svg-fs-exit');
|
||
enter.style.display = isFullscreen ? 'none' : '';
|
||
exit.style.display = isFullscreen ? '' : 'none';
|
||
if (screen.orientation && screen.orientation.lock) {
|
||
if (isFullscreen) screen.orientation.lock('landscape').catch(function(){});
|
||
else screen.orientation.unlock();
|
||
}
|
||
});
|
||
|
||
// ── Theater mode ──────────────────────────────────────
|
||
if (theaterBtn) {
|
||
theaterBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
isTheater = !isTheater;
|
||
wrap.classList.toggle('theater', isTheater);
|
||
theaterBtn.querySelector('.ytp-svg-theater-off').style.display = isTheater ? 'none' : '';
|
||
theaterBtn.querySelector('.ytp-svg-theater-on').style.display = isTheater ? '' : 'none';
|
||
|
||
const layout = document.querySelector('.video-layout-container');
|
||
const videoSection = document.querySelector('.yt-video-section');
|
||
|
||
if (layout && videoSection) {
|
||
const isPortrait = wrap.classList.contains('portrait');
|
||
if (isTheater) {
|
||
layout.insertBefore(wrap, layout.firstChild);
|
||
// Portrait videos are tall/narrow — stack everything below.
|
||
// Landscape/ultrawide — keep comments and sidebar side by side.
|
||
layout.classList.add(isPortrait ? 'theater-mode-portrait' : 'theater-mode');
|
||
} else {
|
||
videoSection.insertBefore(wrap, videoSection.firstChild);
|
||
layout.classList.remove('theater-mode', 'theater-mode-portrait');
|
||
}
|
||
}
|
||
showControls();
|
||
});
|
||
}
|
||
|
||
// ── Loop ──────────────────────────────────────────────
|
||
let isLooping = localStorage.getItem('ytpLoop') === '1';
|
||
video.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;
|
||
video.loop = isLooping;
|
||
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
|
||
applyLoopState();
|
||
});
|
||
}
|
||
|
||
// ── Wake Lock (keep screen on while playing) ──────────
|
||
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' && !video.paused) requestWakeLock();
|
||
});
|
||
|
||
// ── Picture-in-Picture ────────────────────────────────
|
||
if (pipBtn) {
|
||
if (!document.pictureInPictureEnabled) {
|
||
pipBtn.classList.add('unsupported');
|
||
} else {
|
||
pipBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
if (document.pictureInPictureElement) {
|
||
document.exitPictureInPicture();
|
||
} else {
|
||
video.requestPictureInPicture().catch(() => {});
|
||
}
|
||
showControls();
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Video events ──────────────────────────────────────
|
||
video.addEventListener('play', () => { updatePlayIcon(); resetHideTimer(); largePlay.classList.remove('visible'); requestWakeLock(); });
|
||
video.addEventListener('pause', () => {
|
||
if (userSeeking) return; // suppress mid-seek pause events
|
||
updatePlayIcon(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock();
|
||
});
|
||
video.addEventListener('seeked', () => {
|
||
userSeeking = false;
|
||
if (wasPlayingBeforeSeek) {
|
||
// Resume if the browser/HLS.js paused the video during seeking
|
||
if (video.paused) video.play().catch(() => {});
|
||
largePlay.classList.remove('visible');
|
||
resetHideTimer();
|
||
}
|
||
});
|
||
video.addEventListener('timeupdate', updateProgress);
|
||
video.addEventListener('progress', updateProgress);
|
||
video.addEventListener('durationchange', () => { timeDur.textContent = fmt(video.duration); });
|
||
video.addEventListener('waiting', () => spinner.classList.add('active'));
|
||
video.addEventListener('playing', () => spinner.classList.remove('active'));
|
||
video.addEventListener('canplay', () => spinner.classList.remove('active'));
|
||
function navigateNext() {
|
||
const url = shuffleOn ? getShuffledNextUrl() : NEXT_URL;
|
||
if (window._ytpNavOverride?.next) { window._ytpNavOverride.next(url); return; }
|
||
if (url) window.location.href = url;
|
||
}
|
||
function navigatePrev() {
|
||
const url = shuffleOn ? getShuffledPrevUrl() : PREV_URL;
|
||
if (window._ytpNavOverride?.prev) { window._ytpNavOverride.prev(url); return; }
|
||
if (url) window.location.href = url;
|
||
}
|
||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||
|
||
video.addEventListener('ended', () => {
|
||
updatePlayIcon();
|
||
largePlay.classList.add('visible');
|
||
clearTimeout(hideTimer);
|
||
releaseWakeLock();
|
||
if (window._plOnVideoEnd) {
|
||
window._plOnVideoEnd();
|
||
} else if (autoplayOn || isLooping) {
|
||
navigateNext();
|
||
}
|
||
});
|
||
video.addEventListener('volumechange', updateVolumeIcon);
|
||
|
||
// ── 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();
|
||
video.muted = !video.muted;
|
||
if (!video.muted && video.volume === 0) video.volume = 0.5;
|
||
updateVolumeIcon();
|
||
localStorage.setItem('ytpMuted', video.muted ? '1' : '0');
|
||
showControls(); break;
|
||
case 'f': case 'F':
|
||
e.preventDefault(); toggleFullscreen(); break;
|
||
case 't': case 'T':
|
||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||
case 'i': case 'I':
|
||
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
||
case 'j': case 'J':
|
||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||
case 'l': case 'L':
|
||
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
|
||
case 'ArrowLeft':
|
||
e.preventDefault(); seekRelative(-5); showControls(); break;
|
||
case 'ArrowRight':
|
||
e.preventDefault(); seekRelative(5); showControls(); break;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
video.volume = Math.min(1, video.volume + 0.05);
|
||
video.muted = false;
|
||
updateVolumeIcon();
|
||
showControls(); break;
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
video.volume = Math.max(0, video.volume - 0.05);
|
||
updateVolumeIcon();
|
||
showControls(); break;
|
||
default:
|
||
if (e.key >= '0' && e.key <= '9') {
|
||
e.preventDefault();
|
||
if (video.duration) video.currentTime = (parseInt(e.key) / 10) * video.duration;
|
||
showControls();
|
||
}
|
||
}
|
||
});
|
||
|
||
function seekRelative(secs) {
|
||
if (!video.duration) return;
|
||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + secs));
|
||
updateProgress();
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────
|
||
function init() {
|
||
const savedVol = localStorage.getItem('ytpVolume');
|
||
const savedMuted = localStorage.getItem('ytpMuted');
|
||
video.volume = savedVol ? parseInt(savedVol) / 100 : 0.5;
|
||
video.muted = true; // start muted so autoplay is never blocked
|
||
updateVolumeIcon();
|
||
|
||
// After first play, restore user's preferred mute state
|
||
video.addEventListener('playing', function restoreSound() {
|
||
if (savedMuted !== '1') {
|
||
video.muted = false;
|
||
updateVolumeIcon();
|
||
}
|
||
}, { once: true });
|
||
|
||
// If user returned from mini-player, seek to the saved timestamp
|
||
const miniRaw = sessionStorage.getItem('ytpMiniState');
|
||
let miniSeekTime = 0;
|
||
if (miniRaw) {
|
||
try {
|
||
const ms = JSON.parse(miniRaw);
|
||
if (ms && ms.time > 0) miniSeekTime = ms.time;
|
||
} catch(e) {}
|
||
sessionStorage.removeItem('ytpMiniState');
|
||
}
|
||
|
||
// canplay is a belt-and-suspenders fallback — also mute before play
|
||
video.addEventListener('canplay', function autoStart() {
|
||
video.removeEventListener('canplay', autoStart);
|
||
if (miniSeekTime > 0) {
|
||
video.currentTime = miniSeekTime;
|
||
miniSeekTime = 0;
|
||
}
|
||
if (video.paused) {
|
||
video.muted = true;
|
||
video.play().catch(() => {});
|
||
}
|
||
});
|
||
|
||
// Update duration when metadata is ready
|
||
video.addEventListener('loadedmetadata', () => {
|
||
timeDur.textContent = fmt(video.duration);
|
||
});
|
||
|
||
// Load source
|
||
loadHlsJs(initSource);
|
||
|
||
// Initial state
|
||
largePlay.classList.add('visible');
|
||
showControls();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
|
||
})();
|
||
</script>
|
||
@endonce
|