ghassan e74862a24d Fix video autoplay on page load — trigger play on MANIFEST_PARSED
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>
2026-05-16 13:58:24 +03:00

1348 lines
51 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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