ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

1508 lines
59 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
// Force 16:9 for every video — orientation classes intentionally disabled
$orientationClass = '';
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$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"
data-progress-url="{{ route('videos.viewProgress', $video) }}"
data-video-id="{{ $video->id }}">
<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">
{{-- Mini player toggle desktop-only, persisted in localStorage --}}
<div class="ytp-settings-item" id="ytpMiniToggleRow" onclick="(function(el){var on=!(window._ytpMiniEnabled&&window._ytpMiniEnabled());window._ytpMiniSetEnabled(on);el.querySelector('.ytp-settings-val').textContent=on?'On':'Off';})(this)">
<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>
<span>Mini player</span>
<span class="ytp-settings-val">On</span>
</div>
<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>
{{-- 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>
{{-- Loop outside gear, immediately to its right --}}
<button class="ytp-button ytp-loop-btn" id="ytpLoopBtn" title="Loop">
<svg viewBox="0 0 24 24"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
{{-- 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-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
/* 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: 75vh; width: auto; max-width: 100%; }
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
.ytp-wrap.ultrawide { max-height: 50vw; }
.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; }
/* Standalone loop button */
.ytp-loop-btn svg { opacity: .75; transition: opacity .15s, fill .15s; }
.ytp-loop-btn:hover svg { opacity: 1; }
.ytp-loop-btn.is-on svg { fill: #e61e1e; opacity: 1; }
/* 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 loopBtn = document.getElementById('ytpLoopBtn');
// ── 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;
window._ytpMasterHls = @json($hlsUrl);
window._ytpMasterMp4 = @json($mp4Url);
window._ytpWasPlaying = false;
video.addEventListener('play', function () { window._ytpWasPlaying = true; });
video.addEventListener('pause', function () { window._ytpWasPlaying = false; });
function initSource() {
video.muted = true;
video.autoplay = true;
/* Resume handoff from the mini player: ?t=<sec> seeks the video to that
position once metadata is ready. One-shot — only the initial load. */
try {
var _qs = new URLSearchParams(location.search);
var _t = parseInt(_qs.get('t') || '0', 10);
if (_t > 0) {
video.addEventListener('loadedmetadata', function () {
if (_t < (video.duration || Infinity)) {
try { video.currentTime = _t; } catch (e) {}
}
}, { once: true });
}
} catch (e) {}
if (HLS_URL && window.Hls && Hls.isSupported()) {
window._ytpHls = new Hls({ startLevel: -1 });
// Register MANIFEST_PARSED before loadSource to avoid cache race condition
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
video.muted = true;
video.play().catch(function(){});
});
window._ytpHls.loadSource(HLS_URL);
window._ytpHls.attachMedia(video);
} else if (HLS_URL && video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari) — set src then play on loadedmetadata
video.src = HLS_URL;
video.load();
video.addEventListener('loadedmetadata', function() {
video.muted = true;
video.play().catch(function(){});
}, { once: true });
} else {
// Plain MP4 — play on loadedmetadata (canplay/MANIFEST_PARSED don't apply)
video.src = MP4_URL;
video.load();
video.addEventListener('loadedmetadata', function() {
video.muted = true;
video.play().catch(function(){});
}, { once: true });
}
}
// Reinitialize source after SPA transition — called by playlist overlay scripts
window._ytpLoadSource = function(hlsUrl, mp4Url) {
window._ytpMasterHls = hlsUrl || null;
window._ytpMasterMp4 = mp4Url || null;
const _vol = video.volume;
const _muted = video.muted;
if (window._ytpHls) { window._ytpHls.destroy(); window._ytpHls = null; }
video.muted = true;
video.autoplay = true;
if (hlsUrl && window.Hls && Hls.isSupported()) {
window._ytpHls = new Hls({ startLevel: -1 });
window._ytpHls.once(Hls.Events.MANIFEST_PARSED, function() {
video.volume = _vol;
video.muted = _muted;
video.play().catch(function(){});
});
window._ytpHls.loadSource(hlsUrl);
window._ytpHls.attachMedia(video);
} else if (hlsUrl && video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = hlsUrl;
video.load();
video.volume = _vol;
video.addEventListener('loadedmetadata', function() {
video.muted = _muted;
video.play().catch(function(){});
}, { once: true });
} else {
video.src = mp4Url;
video.load();
video.volume = _vol;
video.addEventListener('loadedmetadata', function() {
video.muted = _muted;
video.play().catch(function(){});
}, { once: true });
}
};
// Load HLS.js from CDN then init
function loadHlsJs(cb) {
if (window.Hls) { cb(); return; }
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5/dist/hls.min.js';
s.onload = cb;
document.head.appendChild(s);
}
// ── Helpers ───────────────────────────────────────────
function fmt(s) {
if (!isFinite(s) || isNaN(s)) return '0:00';
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
return m + ':' + String(sec).padStart(2,'0');
}
function updatePlayIcon() {
const playSvg = playBtn.querySelector('.ytp-svg-play');
const pauseSvg = playBtn.querySelector('.ytp-svg-pause');
if (video.paused) {
playSvg.style.display = '';
pauseSvg.style.display = 'none';
} else {
playSvg.style.display = 'none';
pauseSvg.style.display = '';
}
}
function updateVolumeIcon() {
const svgs = ['ytp-svg-vol3','ytp-svg-vol2','ytp-svg-vol1','ytp-svg-vol0'];
const vol = video.volume;
const muted = video.muted || vol === 0;
svgs.forEach(c => muteBtn.querySelector('.'+c).style.display = 'none');
if (muted) muteBtn.querySelector('.ytp-svg-vol0').style.display = '';
else if (vol > .5) muteBtn.querySelector('.ytp-svg-vol3').style.display = '';
else if (vol > .1) muteBtn.querySelector('.ytp-svg-vol2').style.display = '';
else muteBtn.querySelector('.ytp-svg-vol1').style.display = '';
volRange.value = muted ? 0 : Math.round(vol * 100);
volRange.style.setProperty('--vol', (muted ? 0 : vol * 100) + '%');
}
function updateProgress() {
if (!video.duration) return;
const pct = (video.currentTime / video.duration) * 100;
played.style.width = pct + '%';
scrubber.parentElement.style.left = pct + '%';
timeCur.textContent = fmt(video.currentTime);
// buffered
if (video.buffered.length > 0) {
const bufEnd = video.buffered.end(video.buffered.length - 1);
buffered.style.width = ((bufEnd / video.duration) * 100) + '%';
}
}
// ── Controls visibility ───────────────────────────────
function showControls() {
player.classList.remove('controls-hidden');
resetHideTimer();
}
function resetHideTimer() {
clearTimeout(hideTimer);
if (!video.paused) {
hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000);
}
}
function onActivity() { showControls(); }
// ── Play / Pause ──────────────────────────────────────
function togglePlay() {
if (video.paused) {
video.play();
largePlay.classList.remove('visible');
} else {
video.pause();
largePlay.classList.add('visible');
}
}
playBtn.addEventListener('click', e => { e.stopPropagation(); togglePlay(); showControls(); });
// ── Click on video to play/pause, dbl-click seek/fullscreen ──
let clickTimer = null;
player.addEventListener('click', e => {
if (e.target === settingsBtn || settingsPanel.contains(e.target)) return;
if (progCont.contains(e.target)) return;
clearTimeout(clickTimer);
clickTimer = setTimeout(() => { togglePlay(); }, 200);
});
player.addEventListener('dblclick', e => {
clearTimeout(clickTimer);
const rect = player.getBoundingClientRect();
const x = e.clientX - rect.left;
if (x < rect.width * .4) {
seekRelative(-10);
flashDbl(dblLeft);
} else if (x > rect.width * .6) {
seekRelative(10);
flashDbl(dblRight);
} else {
toggleFullscreen();
}
});
function flashDbl(el) {
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 600);
}
// ── Mouse move / touch on player ─────────────────────
player.addEventListener('mousemove', onActivity);
player.addEventListener('touchstart', onActivity, { passive: true });
// ── Volume ────────────────────────────────────────────
muteBtn.addEventListener('click', e => {
e.stopPropagation();
video.muted = !video.muted;
if (!video.muted && video.volume === 0) video.volume = 0.2;
updateVolumeIcon();
localStorage.setItem('ytpMuted', video.muted ? '1' : '0');
showControls();
});
volRange.addEventListener('input', e => {
e.stopPropagation();
const v = parseInt(e.target.value) / 100;
video.volume = v;
video.muted = v === 0;
updateVolumeIcon();
localStorage.setItem('ytpVolume', e.target.value);
localStorage.setItem('ytpMuted', v === 0 ? '1' : '0');
});
volRange.addEventListener('click', e => e.stopPropagation());
// ── Progress bar scrubbing ────────────────────────────
function seekTo(pct) {
if (!video.duration) return;
if (!userSeeking) wasPlayingBeforeSeek = !video.paused;
userSeeking = true;
video.currentTime = pct * video.duration;
updateProgress();
}
function progressPct(e) {
const rect = progBar.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
}
function touchProgressPct(e) {
const rect = progBar.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));
}
progCont.addEventListener('mousemove', e => {
const pct = progressPct(e);
hoverTime.textContent = fmt(pct * (video.duration || 0));
hoverTime.style.left = (pct * 100) + '%';
if (isDragging) {
seekTo(pct);
played.style.width = (pct * 100) + '%';
scrubber.parentElement.style.left = (pct * 100) + '%';
}
});
progCont.addEventListener('mousedown', e => {
e.preventDefault();
isDragging = true;
progBar.classList.add('dragging');
seekTo(progressPct(e));
});
document.addEventListener('mouseup', () => {
if (isDragging) { isDragging = false; progBar.classList.remove('dragging'); }
});
document.addEventListener('mousemove', e => {
if (isDragging) {
const rect = progBar.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
seekTo(pct);
}
});
progCont.addEventListener('touchstart', e => {
isDragging = true;
progBar.classList.add('dragging');
seekTo(touchProgressPct(e));
}, { passive: true });
progCont.addEventListener('touchmove', e => {
if (isDragging) seekTo(touchProgressPct(e));
}, { passive: true });
progCont.addEventListener('touchend', e => { e.preventDefault(); isDragging = false; progBar.classList.remove('dragging'); });
// ── Settings panel ────────────────────────────────────
settingsBtn.addEventListener('click', e => {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
if (!open) { speedPanel.classList.remove('open'); speedRow.style.display = ''; }
/* Sync the mini-player toggle row's label to the current preference each
time the gear opens, so reloading the page or toggling from the music
player keeps the indicator honest. */
if (open) {
const miniRow = document.getElementById('ytpMiniToggleRow');
if (miniRow) {
const v = miniRow.querySelector('.ytp-settings-val');
const on = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (v) v.textContent = on ? 'On' : 'Off';
}
}
showControls();
clearTimeout(hideTimer); // keep controls visible while settings open
});
document.addEventListener('click', e => {
if (!document.getElementById('ytpSettingsWrap').contains(e.target)) {
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
}
});
if (speedRow) {
speedRow.addEventListener('click', e => {
e.stopPropagation();
speedRow.style.display = 'none';
speedPanel.classList.add('open');
});
}
if (speedBack) {
speedBack.addEventListener('click', e => {
e.stopPropagation();
speedPanel.classList.remove('open');
speedRow.style.display = '';
});
}
speedOpts.forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
const speed = parseFloat(opt.dataset.speed);
video.playbackRate = speed;
currentSpeed = speed;
speedOpts.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
speedLabel.textContent = speed === 1 ? 'Normal' : speed;
settingsPanel.classList.remove('open');
speedPanel.classList.remove('open');
speedRow.style.display = '';
});
});
// ── Fullscreen ────────────────────────────────────────
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrap.requestFullscreen && wrap.requestFullscreen();
}
}
fsBtn.addEventListener('click', e => { e.stopPropagation(); toggleFullscreen(); showControls(); });
document.addEventListener('fullscreenchange', () => {
isFullscreen = !!document.fullscreenElement;
wrap.classList.toggle('ytp-fullscreen', isFullscreen);
const enter = fsBtn.querySelector('.ytp-svg-fs-enter');
const exit = fsBtn.querySelector('.ytp-svg-fs-exit');
enter.style.display = isFullscreen ? 'none' : '';
exit.style.display = isFullscreen ? '' : 'none';
if (screen.orientation && screen.orientation.lock) {
if (isFullscreen) screen.orientation.lock('landscape').catch(function(){});
else screen.orientation.unlock();
}
});
// ── Theater mode ──────────────────────────────────────
if (theaterBtn) {
theaterBtn.addEventListener('click', e => {
e.stopPropagation();
isTheater = !isTheater;
wrap.classList.toggle('theater', isTheater);
theaterBtn.querySelector('.ytp-svg-theater-off').style.display = isTheater ? 'none' : '';
theaterBtn.querySelector('.ytp-svg-theater-on').style.display = isTheater ? '' : 'none';
const layout = document.querySelector('.video-layout-container');
const videoSection = document.querySelector('.yt-video-section');
if (layout && videoSection) {
const isPortrait = wrap.classList.contains('portrait');
if (isTheater) {
layout.insertBefore(wrap, layout.firstChild);
// Portrait videos are tall/narrow — stack everything below.
// Landscape/ultrawide — keep comments and sidebar side by side.
layout.classList.add(isPortrait ? 'theater-mode-portrait' : 'theater-mode');
} else {
videoSection.insertBefore(wrap, videoSection.firstChild);
layout.classList.remove('theater-mode', 'theater-mode-portrait');
}
}
showControls();
});
}
// ── Loop ──────────────────────────────────────────────
let isLooping = localStorage.getItem('ytpLoop') === '1';
video.loop = isLooping;
function applyLoopState() {
if (!loopBtn) return;
loopBtn.classList.toggle('is-on', isLooping);
loopBtn.title = isLooping ? 'Loop: On' : 'Loop';
}
applyLoopState();
if (loopBtn) {
loopBtn.addEventListener('click', e => {
e.stopPropagation();
isLooping = !isLooping;
video.loop = isLooping;
localStorage.setItem('ytpLoop', isLooping ? '1' : '0');
applyLoopState();
showControls();
});
}
// ── Wake Lock (keep screen on while playing) ──────────
let wakeLock = null;
async function requestWakeLock() {
if (!('wakeLock' in navigator) || wakeLock) return;
try { wakeLock = await navigator.wakeLock.request('screen'); } catch(e) {}
}
function releaseWakeLock() {
if (wakeLock) { wakeLock.release(); wakeLock = null; }
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !video.paused) requestWakeLock();
});
// ── Picture-in-Picture ────────────────────────────────
if (pipBtn) {
if (!document.pictureInPictureEnabled) {
pipBtn.classList.add('unsupported');
} else {
pipBtn.addEventListener('click', e => {
e.stopPropagation();
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
video.requestPictureInPicture().catch(() => {});
}
showControls();
});
}
}
// ── Video events ──────────────────────────────────────
video.addEventListener('play', () => { updatePlayIcon(); resetHideTimer(); largePlay.classList.remove('visible'); requestWakeLock(); });
video.addEventListener('pause', () => {
if (userSeeking) return; // suppress mid-seek pause events
updatePlayIcon(); showControls(); largePlay.classList.add('visible'); clearTimeout(hideTimer); releaseWakeLock();
});
video.addEventListener('seeked', () => {
userSeeking = false;
if (wasPlayingBeforeSeek) {
// Resume if the browser/HLS.js paused the video during seeking
if (video.paused) video.play().catch(() => {});
largePlay.classList.remove('visible');
resetHideTimer();
}
});
video.addEventListener('timeupdate', updateProgress);
video.addEventListener('progress', updateProgress);
video.addEventListener('durationchange', () => { timeDur.textContent = fmt(video.duration); });
video.addEventListener('waiting', () => spinner.classList.add('active'));
video.addEventListener('playing', () => spinner.classList.remove('active'));
video.addEventListener('canplay', () => spinner.classList.remove('active'));
function navigateNext() {
const url = shuffleOn ? getShuffledNextUrl() : NEXT_URL;
if (window._ytpNavOverride?.next) { window._ytpNavOverride.next(url); return; }
if (url) window.location.href = url;
}
function navigatePrev() {
const url = shuffleOn ? getShuffledPrevUrl() : PREV_URL;
if (window._ytpNavOverride?.prev) { window._ytpNavOverride.prev(url); return; }
if (url) window.location.href = url;
}
window._ytpNav = { next: navigateNext, prev: navigatePrev };
video.addEventListener('ended', () => {
updatePlayIcon();
largePlay.classList.add('visible');
clearTimeout(hideTimer);
releaseWakeLock();
if (window._plOnVideoEnd) {
window._plOnVideoEnd();
} else if (autoplayOn || isLooping) {
navigateNext();
}
});
video.addEventListener('volumechange', updateVolumeIcon);
// ── Keyboard shortcuts ────────────────────────────────
document.addEventListener('keydown', e => {
const tag = document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement.isContentEditable) return;
switch (e.key) {
case ' ':
case 'k': case 'K':
e.preventDefault(); togglePlay(); showControls(); break;
case 'm': case 'M':
e.preventDefault();
video.muted = !video.muted;
if (!video.muted && video.volume === 0) video.volume = 0.5;
updateVolumeIcon();
localStorage.setItem('ytpMuted', video.muted ? '1' : '0');
showControls(); break;
case 'f': case 'F':
e.preventDefault(); toggleFullscreen(); break;
case 't': case 'T':
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
case 'i': case 'I':
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
case 'j': case 'J':
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
case 'l': case 'L':
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
case 'ArrowLeft':
e.preventDefault(); seekRelative(-5); showControls(); break;
case 'ArrowRight':
e.preventDefault(); seekRelative(5); showControls(); break;
case 'ArrowUp':
e.preventDefault();
video.volume = Math.min(1, video.volume + 0.05);
video.muted = false;
updateVolumeIcon();
showControls(); break;
case 'ArrowDown':
e.preventDefault();
video.volume = Math.max(0, video.volume - 0.05);
updateVolumeIcon();
showControls(); break;
default:
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
if (video.duration) video.currentTime = (parseInt(e.key) / 10) * video.duration;
showControls();
}
}
});
function seekRelative(secs) {
if (!video.duration) return;
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + secs));
updateProgress();
}
// ── Init ──────────────────────────────────────────────
function init() {
const savedVol = localStorage.getItem('ytpVolume');
const savedMuted = localStorage.getItem('ytpMuted');
video.volume = savedVol ? parseInt(savedVol) / 100 : 0.5;
video.muted = true; // start muted so autoplay is never blocked
updateVolumeIcon();
// After first play, restore user's preferred mute state
video.addEventListener('playing', function restoreSound() {
if (savedMuted !== '1') {
video.muted = false;
updateVolumeIcon();
}
}, { once: true });
// If user returned from mini-player, seek to the saved timestamp
const miniRaw = sessionStorage.getItem('ytpMiniState');
let miniSeekTime = 0;
if (miniRaw) {
try {
const ms = JSON.parse(miniRaw);
if (ms && ms.time > 0) miniSeekTime = ms.time;
} catch(e) {}
sessionStorage.removeItem('ytpMiniState');
}
// canplay is a belt-and-suspenders fallback — also mute before play
video.addEventListener('canplay', function autoStart() {
video.removeEventListener('canplay', autoStart);
if (miniSeekTime > 0) {
video.currentTime = miniSeekTime;
miniSeekTime = 0;
}
if (video.paused) {
video.muted = true;
video.play().catch(() => {});
}
});
// Update duration when metadata is ready
video.addEventListener('loadedmetadata', () => {
timeDur.textContent = fmt(video.duration);
});
// Load source
loadHlsJs(initSource);
// Initial state
largePlay.classList.add('visible');
showControls();
// Scroll-based mini player: watch when #ytpWrap leaves the viewport.
// Desktop-only — on mobile the fixed bottom-nav + locked scroll model
// make a floating overlay disruptive.
if (window.IntersectionObserver && window._miniPlayer && window.innerWidth > 768) {
var _scrollRoot = null; /* desktop: window scrolls */
var _scrollMiniOn = false; /* guard against rapid toggling / initial fire */
new IntersectionObserver(function (entries) {
var e0 = entries[0];
/* Only activate after the video has actually started playing (_ytpWasPlaying).
Using !video.paused was unreliable: autoplay fires asynchronously and the
initial IntersectionObserver callback could run before HLS.js even attaches,
teleporting the element before it ever played in the main player. */
var miniAllowed = !window._ytpMiniEnabled || window._ytpMiniEnabled();
if (!e0.isIntersecting && !_scrollMiniOn && window._ytpWasPlaying && miniAllowed && !window._miniPlayer.isNavMode()) {
_scrollMiniOn = true;
window._miniPlayer.activateScroll(
document.title.replace(/\s*\|.*$/, '').trim(),
window.location.href
);
} else if (e0.isIntersecting && _scrollMiniOn) {
_scrollMiniOn = false;
window._miniPlayer.deactivateScroll();
}
}, { root: _scrollRoot, threshold: 0.15 }).observe(wrap);
/* User clicked the X on the mini while still on the video page —
reset the flag so a subsequent scroll-away re-activates it. */
window.addEventListener('miniplayer:scroll-closed', function () {
_scrollMiniOn = false;
});
}
}
document.addEventListener('DOMContentLoaded', init);
// ── View-progress heartbeat ──────────────────────────────
(function() {
const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
let _hbVideoId = null;
let _hbUrl = null;
let _hbLast = 0;
let _hbCompleted = false;
function _refreshHbTarget() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const vid = parseInt(wrap.dataset.videoId || '0', 10);
if (vid && vid !== _hbVideoId) {
_hbVideoId = vid;
_hbUrl = wrap.dataset.progressUrl || null;
_hbLast = 0;
_hbCompleted = false;
}
}
function _sendHb(completed) {
if (!_hbUrl) return;
const v = document.getElementById('videoPlayer');
if (!v) return;
const cur = Math.floor(v.currentTime || 0);
if (!completed && cur <= _hbLast) return;
const body = new URLSearchParams({ watched_seconds: cur, completed: completed ? '1' : '0' });
try {
if (completed && navigator.sendBeacon) {
const blob = new Blob([body.toString() + '&_token=' + _csrf], { type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(_hbUrl, blob);
} else {
fetch(_hbUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: body.toString(),
keepalive: true,
credentials: 'same-origin',
}).catch(function() {});
}
_hbLast = cur;
if (completed) _hbCompleted = true;
} catch (e) {}
}
function _hbBind() {
const v = document.getElementById('videoPlayer');
if (!v || v._hbBound) return;
v._hbBound = true;
_refreshHbTarget();
v.addEventListener('ended', () => _sendHb(true));
v.addEventListener('pause', () => _sendHb(false));
}
setInterval(function() { _refreshHbTarget(); if (!_hbCompleted) _sendHb(false); }, 5000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') _sendHb(false);
});
window.addEventListener('pagehide', function() { _sendHb(false); });
document.addEventListener('DOMContentLoaded', _hbBind);
// also rebind after SPA source swaps
const _origLoad = window._ytpLoadSource;
if (typeof _origLoad === 'function') {
window._ytpLoadSource = function() {
_refreshHbTarget();
return _origLoad.apply(this, arguments);
};
}
})();
})();
</script>
@endonce