Previously, play() was only called from the canplay listener which can fire too late or be missed with HLS.js. Now also triggers on Hls.Events.MANIFEST_PARSED (the earliest reliable point), and calls play() directly after video.load() for MP4/native-HLS paths. Also fixes _ytpLoadSource (SPA transitions) to use the same pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1348 lines
51 KiB
PHP
1348 lines
51 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 tryAutoplay() {
|
||
video.muted = true;
|
||
video.autoplay = true;
|
||
video.play().catch(function(){});
|
||
}
|
||
|
||
function initSource() {
|
||
video.muted = true;
|
||
video.autoplay = true;
|
||
if (HLS_URL && window.Hls && Hls.isSupported()) {
|
||
window._ytpHls = new Hls({ startLevel: -1 });
|
||
window._ytpHls.loadSource(HLS_URL);
|
||
window._ytpHls.attachMedia(video);
|
||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, tryAutoplay);
|
||
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
video.src = HLS_URL;
|
||
video.load();
|
||
video.play().catch(function(){});
|
||
} else {
|
||
video.src = MP4_URL;
|
||
video.load();
|
||
video.play().catch(function(){});
|
||
}
|
||
}
|
||
|
||
// 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.loadSource(hlsUrl);
|
||
window._ytpHls.attachMedia(video);
|
||
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
});
|
||
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
video.src = hlsUrl;
|
||
video.load();
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
} else {
|
||
video.src = mp4Url;
|
||
video.load();
|
||
video.muted = false;
|
||
video.play().catch(function(){});
|
||
}
|
||
};
|
||
|
||
// 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';
|
||
});
|
||
|
||
// ── 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 fires for ALL source types (HLS.js, MP4, Safari native HLS)
|
||
// once the browser has enough data to start — most reliable autoplay trigger
|
||
video.addEventListener('canplay', function autoStart() {
|
||
video.removeEventListener('canplay', autoStart);
|
||
if (miniSeekTime > 0) {
|
||
video.currentTime = miniSeekTime;
|
||
miniSeekTime = 0;
|
||
}
|
||
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
|