Files
deploy-test/animex/video_player.html
2026-03-29 20:52:57 -05:00

2192 lines
70 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>Animex Player</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<!-- HLS.js -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- Chromecast SDK -->
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
<style>
:root {
--player-accent-color: #ff9500;
--player-controls-bg: rgba(0, 0, 0, 0.7);
--player-text-color: #ffffff;
--player-icon-size: 1.2em;
--player-mobile-icon-size: 1.5em;
--marker-intro-color: #3498db;
--marker-outro-color: #e74c3c;
--font-family: "Inter", sans-serif;
}
body,
html {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background-color: #141414;
font-family: var(--font-family);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
}
.video-player-container {
width: 90vw;
max-width: 1000px;
aspect-ratio: 16 / 9;
position: relative;
background-color: #000;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
outline: none;
}
.video-player-container.fullscreen-mode,
.video-player-container.fullscreen {
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
max-height: none !important;
top: 0 !important;
left: 0 !important;
border-radius: 0 !important;
z-index: 2147483647;
position: fixed;
}
video#customPlayer {
width: 100%;
height: 100%;
display: block;
background: #000;
object-fit: contain;
}
/* Native Captions Style Fix */
::cue {
background-color: rgba(0, 0, 0, 0.8);
color: #ffffff;
font-family: var(--font-family);
text-align: center;
white-space: pre-wrap;
line-height: 1.25;
padding: 0.25em 0.6em;
font-size: clamp(1rem, 1.7vw, 1.6rem);
font-variant-ligatures: none;
}
video::cue-region {
text-align: center;
}
.custom-captions-overlay {
position: absolute;
left: 50%;
bottom: 10%;
transform: translateX(-50%);
z-index: 10;
display: inline-block;
width: auto;
max-width: 90vw;
box-sizing: border-box;
padding: 0.35em 0.75em;
border-radius: 12px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
text-align: center;
line-height: 1.25;
font-size: clamp(1rem, 1.6vw, 1.8rem);
letter-spacing: 0.01em;
white-space: pre-wrap;
word-break: break-word;
display: none;
pointer-events: none;
}
.custom-captions-overlay span {
display: block;
margin: 0.1em 0;
}
@media (min-width: 769px) {
::cue {
font-size: clamp(1.4rem, 1.1vw, 2.2rem);
line-height: 1.15;
padding: 0.3em 0.8em;
}
}
/* --- CONTROLS BAR --- */
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 15px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.9) 0%,
rgba(0, 0, 0, 0.5) 70%,
rgba(0, 0, 0, 0) 100%
);
display: flex;
flex-direction: column;
opacity: 1;
transition: opacity 0.3s ease-in-out;
z-index: 50;
}
.video-player-container:not(.controls-active) .video-controls {
opacity: 0;
pointer-events: none;
}
.video-player-container.paused .video-controls {
opacity: 1;
pointer-events: auto;
}
/* --- PROGRESS BAR --- */
.progress-bar-container {
width: 100%;
margin-bottom: 10px;
cursor: pointer;
padding: 10px 0;
position: relative;
touch-action: none; /* Prevent scroll on mobile while scrubbing */
}
.progress-bar {
width: 100%;
height: 5px;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 3px;
position: relative;
overflow: hidden;
}
.buffered-bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: rgba(255, 255, 255, 0.4);
width: 0%;
pointer-events: none;
z-index: 1;
transition: width 0.2s ease;
}
.progress-bar-hover {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
width: 0%;
pointer-events: none;
z-index: 2;
}
.progress-filled {
height: 100%;
background-color: var(--player-accent-color);
border-radius: 3px;
width: 0%;
position: absolute;
top: 0;
left: 0;
z-index: 3;
}
.progress-filled::after {
content: "";
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background-color: var(--player-accent-color);
border-radius: 50%;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.2s;
z-index: 4;
}
.video-player-container.controls-active .progress-filled::after {
opacity: 1;
}
.skip-marker {
position: absolute;
top: 0;
height: 100%;
z-index: 2;
opacity: 0.7;
pointer-events: none;
}
.skip-marker.intro {
background-color: var(--marker-intro-color);
}
.skip-marker.outro {
background-color: var(--marker-outro-color);
}
/* Thumbnails */
.thumbnail-tooltip {
position: absolute;
bottom: 25px;
left: 0;
transform: translateX(-50%);
background-color: #000;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
pointer-events: none;
display: none;
flex-direction: column;
align-items: center;
z-index: 60;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.thumbnail-image {
background-repeat: no-repeat;
}
.thumbnail-time {
width: 100%;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
text-align: center;
font-size: 0.8em;
padding: 3px 0;
}
/* --- BUTTONS --- */
.controls-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.controls-left,
.controls-center,
.controls-right {
display: flex;
align-items: center;
gap: 15px;
}
.controls-center {
flex-grow: 1;
justify-content: center;
}
.control-button {
background: none;
border: none;
color: var(--player-text-color);
font-size: var(--player-icon-size);
cursor: pointer;
padding: 8px;
line-height: 1;
position: relative;
transition: color 0.2s;
}
.control-button:hover,
.control-button.active {
color: var(--player-accent-color);
}
/* Cast Button specific styling */
.cast-button {
display: none;
} /* Hidden until SDK loads */
.cast-button.available {
display: block;
}
.time-display {
color: var(--player-text-color);
font-size: 0.85em;
margin-left: 10px;
font-variant-numeric: tabular-nums;
}
/* Volume */
.volume-container {
display: flex;
align-items: center;
position: relative;
}
.volume-slider {
-webkit-appearance: none;
width: 70px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
margin-left: 8px;
background-image: linear-gradient(
var(--player-accent-color),
var(--player-accent-color)
);
background-size: 100% 100%;
background-repeat: no-repeat;
display: none;
}
.video-player-container.desktop .volume-slider {
display: block;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
/* Scrollbar tweaks for menus */
.captions-menu::-webkit-scrollbar, .settings-menu::-webkit-scrollbar {
width: 5px;
}
.captions-menu::-webkit-scrollbar-thumb, .settings-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
/* --- CAPTIONS MENU --- */
.captions-container {
position: relative;
}
.captions-menu {
position: absolute;
bottom: 45px;
right: -10px;
background-color: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 5px 0;
display: none;
flex-direction: column;
min-width: 180px;
max-height: 50vh;
overflow-y: auto;
overflow-x: hidden;
z-index: 55;
backdrop-filter: blur(5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
}
.captions-menu.active {
display: flex;
}
.captions-menu-option {
padding: 10px 15px;
color: #eee;
cursor: pointer;
font-size: 0.9em;
border: none;
background: none;
text-align: left;
width: 100%;
transition: background 0.2s;
}
.captions-menu-option:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.captions-menu-option.selected {
color: var(--player-accent-color);
font-weight: 600;
}
/* --- SETTINGS MENU --- */
.settings-container {
position: relative;
}
.settings-menu {
position: absolute;
bottom: 45px;
right: -10px;
background-color: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 5px 0;
display: none;
flex-direction: column;
min-width: 180px;
max-height: 50vh;
overflow-y: auto;
overflow-x: hidden;
z-index: 55;
backdrop-filter: blur(5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
}
.settings-menu.active {
display: flex;
}
.settings-item {
padding: 10px 15px;
color: #eee;
cursor: pointer;
font-size: 0.9em;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.settings-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.settings-value {
color: var(--player-accent-color);
font-weight: 500;
font-size: 0.9em;
}
/* Toggle Switch for Auto Settings */
.toggle-switch {
position: relative;
width: 34px;
height: 18px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #555;
transition: 0.4s;
border-radius: 18px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--player-accent-color);
}
input:checked + .toggle-slider:before {
transform: translateX(16px);
}
/* Sub Menus */
.submenu-panel {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #141414;
display: none;
flex-direction: column;
border-radius: 6px;
z-index: 56;
}
.settings-menu.show-speed .speed-panel {
display: flex;
position: static;
}
.settings-menu.show-quality .quality-panel {
display: flex;
position: static;
}
.submenu-header {
padding: 10px 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.submenu-option {
padding: 8px 25px;
color: #ccc;
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: space-between;
}
.submenu-option:hover {
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.submenu-option.selected {
color: var(--player-accent-color);
font-weight: 600;
}
.submenu-option.selected::after {
content: "\f00c";
font-family: "Font Awesome 6 Free";
font-weight: 900;
}
/* --- OVERLAYS --- */
.center-status-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.5);
font-size: 4em;
color: rgba(255, 255, 255, 0.9);
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 1.8em;
height: 1.8em;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 20;
opacity: 0;
transition: transform 0.2s, opacity 0.2s;
}
.center-status-icon.animate {
animation: popFade 0.6s ease-out forwards;
}
@keyframes popFade {
0% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
30% {
transform: translate(-50%, -50%) scale(1.1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1.3);
opacity: 0;
}
}
.seek-overlay {
position: absolute;
top: 0;
width: 30%;
height: calc(100% - 60px);
z-index: 10;
}
.seek-overlay.left {
left: 0;
}
.seek-overlay.right {
right: 0;
}
.seek-feedback {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 1.5em;
color: var(--player-text-color);
background-color: rgba(0, 0, 0, 0.7);
padding: 15px;
border-radius: 50%;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
z-index: 22;
opacity: 0;
pointer-events: none;
flex-direction: column;
}
.seek-feedback span {
font-size: 0.4em;
margin-top: 2px;
}
.seek-feedback.left {
left: 15%;
}
.seek-feedback.right {
right: 15%;
}
.seek-feedback.animate {
animation: ripple 0.5s ease-out forwards;
}
@keyframes ripple {
0% {
transform: translateY(-50%) scale(0.8);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translateY(-50%) scale(1.4);
opacity: 0;
}
}
/* Float Buttons */
.float-button-container {
position: absolute;
bottom: 110px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 45;
pointer-events: none;
}
.overlay-btn {
background-color: rgba(20, 20, 20, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
padding: 10px 20px;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: all 0.3s;
align-self: flex-end;
}
.overlay-btn.visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.overlay-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: var(--player-accent-color);
}
.overlay-btn.next-ep {
background-color: rgba(255, 149, 0, 0.95);
}
.overlay-btn.next-ep:hover {
background-color: rgba(255, 149, 0, 0.4);
}
/* Finished Overlay */
.finished-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 60;
backdrop-filter: blur(5px);
opacity: 0;
transition: opacity 0.3s ease;
}
.finished-overlay.visible {
display: flex;
opacity: 1;
pointer-events: auto;
}
.finished-btn {
padding: 12px 30px;
background: var(--player-accent-color);
color: white;
border: none;
border-radius: 6px;
font-size: 1.1em;
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
transition: transform 0.2s;
}
.finished-btn:hover {
transform: scale(1.05);
background-color: #ffaa33;
}
/* Loading */
.loading-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 19;
display: none;
flex-direction: column;
align-items: center;
gap: 15px;
}
.spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--player-accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: var(--player-text-color);
font-size: 1em;
text-align: center;
}
.video-player-container.loading .loading-container,
.video-player-container.processing .loading-container {
display: flex;
}
/* Toast */
.toast-notification {
position: absolute;
top: 20px;
right: 20px;
background-color: rgba(20, 20, 20, 0.9);
color: #fff;
padding: 10px 20px;
border-radius: 4px;
border-left: 3px solid var(--player-accent-color);
z-index: 70;
transform: translateX(120%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
font-size: 0.9em;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
}
.toast-notification.show {
transform: translateX(0);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.control-button {
font-size: 1.1em;
padding: 10px;
}
.controls-main {
justify-content: space-between;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.controls-left,
.controls-center,
.controls-right {
flex-wrap: nowrap;
gap: 8px;
min-width: 0;
align-items: center;
}
.controls-center {
display: none;
}
.controls-left .time-display,
.controls-right .time-display {
display: none;
}
.volume-container {
display: none !important;
}
.float-button-container {
bottom: 80px;
}
#skipBtn {
display: none !important;
}
.seek-feedback {
width: 70px;
height: 70px;
font-size: 2em;
}
.settings-menu,
.captions-menu {
bottom: 50px;
right: 0;
}
.video-controls {
padding: 8px 10px;
}
.volume-slider {
width: 50px;
}
.video-player-container {
width: 100vw;
max-width: 100%;
}
.video-player-container.horizontal-mobile {
transform: scale(1.02);
}
.video-player-container.horizontal-mobile .control-button {
font-size: 1.2em;
}
.video-player-container.horizontal-mobile ::cue {
font-size: 1.2rem;
}
}
</style>
</head>
<body>
<div class="video-player-container" id="videoPlayerContainer" tabindex="0">
<div class="loading-container" id="loadingContainer">
<div class="spinner"></div>
<p class="loading-text" id="loadingText"></p>
</div>
<div class="toast-notification" id="toastNotification">
Resumed from 12:30
</div>
<div class="finished-overlay" id="finishedOverlay">
<h2 id="finishedTitle">Episode Finished</h2>
<button class="finished-btn" id="replayBtn">
<i class="fas fa-redo"></i> Replay
</button>
</div>
<video
id="customPlayer"
playsinline
preload="metadata"
crossorigin="anonymous"
></video>
<div class="custom-captions-overlay" id="customCaptionsOverlay"></div>
<div class="seek-overlay left" id="seekOverlayLeft"></div>
<div class="seek-overlay right" id="seekOverlayRight"></div>
<div class="center-status-icon" id="centerStatusIcon">
<i class="fas fa-play"></i>
</div>
<div class="seek-feedback left" id="seekFeedbackRewind">
<i class="fas fa-undo"></i> <span>-10s</span>
</div>
<div class="seek-feedback right" id="seekFeedbackForward">
<i class="fas fa-redo"></i> <span>+10s</span>
</div>
<div class="float-button-container">
<div class="overlay-btn next-ep" id="nextEpOverlayBtn">
Next Episode <i class="fas fa-step-forward"></i>
</div>
<div class="overlay-btn" id="skipBtn">
Skip <i class="fas fa-forward"></i>
</div>
</div>
<div class="video-controls" id="videoControls">
<div class="progress-bar-container" id="progressBarContainer">
<div class="thumbnail-tooltip" id="thumbnailTooltip">
<div class="thumbnail-image" id="thumbnailImage"></div>
<div class="thumbnail-time" id="thumbnailTime">0:00</div>
</div>
<div class="progress-bar" id="progressBar">
<div class="buffered-bar" id="bufferedBar"></div>
<div class="progress-bar-hover" id="progressBarHover"></div>
<div class="progress-filled" id="progressFilled"></div>
<div class="skip-marker intro" id="introMarker" style="display:none;"></div>
<div class="skip-marker outro" id="outroMarker" style="display:none;"></div>
</div>
</div>
<div class="controls-main">
<div class="controls-left">
<button class="control-button" id="playPauseBtn">
<i class="fas fa-play"></i>
</button>
<div class="volume-container">
<button class="control-button" id="volumeBtn">
<i class="fas fa-volume-up"></i>
</button>
<input
type="range"
class="volume-slider"
id="volumeSlider"
min="0"
max="1"
step="0.01"
value="1"
/>
</div>
<span class="time-display" id="currentTime">0:00</span>
</div>
<div class="controls-center">
<button class="control-button" id="rewindBtn">
<i class="fas fa-undo-alt"></i>
</button>
<button class="control-button" id="forwardBtn">
<i class="fas fa-redo-alt"></i>
</button>
</div>
<div class="controls-right">
<button class="control-button" id="nextEpControlBtn">
<i class="fas fa-step-forward"></i>
</button>
<span class="time-display" id="totalTime">0:00</span>
<!-- Cast Button -->
<button
class="control-button cast-button"
id="castBtn"
aria-label="Cast"
>
<i class="fab fa-chromecast"></i>
</button>
<!-- Captions Menu Container -->
<div class="captions-container" id="captionsContainer">
<button class="control-button" id="captionsBtn" title="Captions/Subtitles">
<i class="fas fa-closed-captioning"></i>
</button>
<div class="captions-menu" id="captionsMenu"></div>
</div>
<div class="settings-container" id="settingsContainer">
<button class="control-button" id="settingsBtn">
<i class="fas fa-cog"></i>
</button>
<div class="settings-menu" id="settingsMenu">
<div class="settings-main">
<div class="settings-item" id="speedMenuTrigger">
<span>Speed</span>
<span class="settings-value" id="currentSpeedVal"
>Normal</span
>
</div>
<div
class="settings-item"
id="qualityMenuTrigger"
style="display: none"
>
<span>Quality</span>
<span class="settings-value" id="currentQualityVal"
>Auto</span
>
</div>
<!-- New Auto Features -->
<div class="settings-item">
<span>Auto Skip</span>
<label class="toggle-switch">
<input type="checkbox" id="autoSkipToggle" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="settings-item">
<span>Auto Next</span>
<label class="toggle-switch">
<input type="checkbox" id="autoNextToggle" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- Speed Subpanel -->
<div class="submenu-panel speed-panel">
<div class="submenu-header" id="closeSpeedMenu">
<i class="fas fa-chevron-left"></i> Playback Speed
</div>
<div class="submenu-option" data-speed="0.5">0.5x</div>
<div class="submenu-option selected" data-speed="1.0">
Normal
</div>
<div class="submenu-option" data-speed="1.25">1.25x</div>
<div class="submenu-option" data-speed="1.5">1.5x</div>
<div class="submenu-option" data-speed="2.0">2x</div>
</div>
<!-- Quality Subpanel -->
<div class="submenu-panel quality-panel" id="qualityList">
<div class="submenu-header" id="closeQualityMenu">
<i class="fas fa-chevron-left"></i> Quality
</div>
</div>
</div>
</div>
<button class="control-button" id="fullscreenBtn">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
</div>
</div>
<!-- FFmpeg Fallback -->
<script>
let ffmpegLoaded = false;
let FFmpeg, FFmpegUtil;
async function loadFFmpeg() {
try {
const s1 = document.createElement("script");
s1.src =
"https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.js";
const s2 = document.createElement("script");
s2.src = "https://unpkg.com/@ffmpeg/util@0.12.1/dist/umd/index.js";
await Promise.all([
new Promise((r, j) => {
s1.onload = r;
s1.onerror = j;
document.head.appendChild(s1);
}),
new Promise((r, j) => {
s2.onload = r;
s2.onerror = j;
document.head.appendChild(s2);
}),
]);
FFmpeg = window.FFmpeg;
FFmpegUtil = window.FFmpegUtil;
ffmpegLoaded = true;
} catch (e) {
console.warn("FFmpeg load failed", e);
}
}
loadFFmpeg();
</script>
<script>
/** ANIMEX PLAYER LOGIC **/
// --- 1. CONFIG & STATE ---
function getQueryParams() {
const params = new URLSearchParams(window.location.search);
const cfg = {
video: params.get("video"),
isStream: params.get("stream") === "true",
full: params.get("full") === "true",
referer: params.get("referer"),
id: params.get("id"),
episode: params.get("episode"),
is_movie: params.get("is_movie") === "true",
skipTimes: null,
thumbnails: null,
series_title: params.get("series_title") || "Unknown Title",
episode_num: params.get("episode_num") || "?",
thumbnail_url: params.get("thumbnail_url"),
};
try {
if (params.get("skip_times"))
cfg.skipTimes = JSON.parse(params.get("skip_times"));
} catch (e) {}
try {
if (params.get("thumbnails")) {
const tStr = params.get("thumbnails");
if (tStr.startsWith('[') || tStr.startsWith('{')) {
cfg.thumbnails = JSON.parse(tStr);
} else {
cfg.thumbnails = tStr;
}
}
} catch (e) {}
return cfg;
}
const config = getQueryParams();
// Elements
const playerContainer = document.getElementById("videoPlayerContainer");
const video = document.getElementById("customPlayer");
const playPauseBtn = document.getElementById("playPauseBtn");
const volumeBtn = document.getElementById("volumeBtn");
const volumeSlider = document.getElementById("volumeSlider");
const currentTimeEl = document.getElementById("currentTime");
const totalTimeEl = document.getElementById("totalTime");
const progressFilled = document.getElementById("progressFilled");
const bufferedBar = document.getElementById("bufferedBar");
const progressBarContainer = document.getElementById(
"progressBarContainer"
);
const fullscreenBtn = document.getElementById("fullscreenBtn");
const nextEpControlBtn = document.getElementById("nextEpControlBtn");
const nextEpOverlayBtn = document.getElementById("nextEpOverlayBtn");
const skipBtn = document.getElementById("skipBtn");
const finishedOverlay = document.getElementById("finishedOverlay");
const replayBtn = document.getElementById("replayBtn");
const loadingContainer = document.getElementById("loadingContainer");
const loadingText = document.getElementById("loadingText");
const toastNotification = document.getElementById("toastNotification");
const settingsBtn = document.getElementById("settingsBtn");
const settingsMenu = document.getElementById("settingsMenu");
const speedMenuTrigger = document.getElementById("speedMenuTrigger");
const qualityMenuTrigger = document.getElementById("qualityMenuTrigger");
const castBtn = document.getElementById("castBtn");
const autoSkipToggle = document.getElementById("autoSkipToggle");
const autoNextToggle = document.getElementById("autoNextToggle");
const captionsMenuBtn = document.getElementById("captionsBtn");
const captionsMenu = document.getElementById("captionsMenu");
const captionOverlay = document.getElementById("customCaptionsOverlay");
let hls = null;
let ffmpeg = null;
let skipTimes = config.skipTimes;
let controlsTimeout;
let isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
let isScrubbing = false;
let wasPausedBeforeScrub = false;
function updateHorizontalMobileMode() {
const isLandscapeMobile =
isMobile && window.innerWidth <= 768 && window.innerWidth > window.innerHeight;
if (isLandscapeMobile) {
playerContainer.classList.add("horizontal-mobile");
} else {
playerContainer.classList.remove("horizontal-mobile");
}
}
window.addEventListener("resize", updateHorizontalMobileMode);
window.addEventListener("orientationchange", updateHorizontalMobileMode);
updateHorizontalMobileMode();
// Auto Settings State
let autoSkip = localStorage.getItem("animex_autoskip") === "true";
let autoNext = localStorage.getItem("animex_autonext") === "true";
// Apply Initial Settings State
autoSkipToggle.checked = autoSkip;
autoNextToggle.checked = autoNext;
autoSkipToggle.addEventListener("change", (e) => {
autoSkip = e.target.checked;
localStorage.setItem("animex_autoskip", autoSkip);
});
autoNextToggle.addEventListener("change", (e) => {
autoNext = e.target.checked;
localStorage.setItem("animex_autonext", autoNext);
});
if (!isMobile) playerContainer.classList.add("desktop");
if (config.is_movie) {
nextEpControlBtn.style.display = "none";
nextEpOverlayBtn.style.display = "none";
finishedOverlay.querySelector("h2").textContent = "Movie Finished";
}
if (config.thumbnail_url) {
// If the thumbnail is a remote URL, use the proxy-image endpoint
if (/^https?:\/\//i.test(config.thumbnail_url)) {
video.poster = `/proxy-image?url=${encodeURIComponent(config.thumbnail_url)}`;
} else {
video.poster = config.thumbnail_url;
}
}
// --- CAPTIONS LOGIC ---
let captionsTracks = [];
let captionsEnabled = true;
function getCaptionsParam() {
try {
const params = new URLSearchParams(window.location.search);
if (params.get("captions")) {
return JSON.parse(params.get("captions"));
}
} catch (e) {}
return[];
}
function setupCaptions() {
const tracks = getCaptionsParam();
captionsTracks = tracks;
// Remove any existing <track> elements
Array.from(video.querySelectorAll('track')).forEach((t) => t.remove());
// Add new <track> elements
tracks.forEach((track, idx) => {
let src = track.file || track.src;
if (/^https?:\/\//i.test(src)) {
src = `/proxy?url=${encodeURIComponent(src)}`; // VTT generally goes through standard proxy
}
const el = document.createElement('track');
el.kind = track.kind || 'subtitles';
el.label = track.label || `Track ${idx + 1}`;
el.srclang = track.srclang || (track.label ? track.label.split(" ")[0].toLowerCase() : 'en');
el.src = src;
if (track.default) el.default = true;
video.appendChild(el);
});
// Build captions menu
buildCaptionsMenu();
// Hide all tracks first, then activate the default or first available track
if (video.textTracks) {
Array.from(video.textTracks).forEach((tt) => {
tt.mode = 'hidden';
});
}
const defaultTrackIndex = captionsTracks.findIndex((t) => t.default === true);
if (defaultTrackIndex >= 0) {
selectCaption(defaultTrackIndex);
} else if (captionsTracks.length > 0) {
selectCaption(0);
} else {
selectCaption(-1);
}
attachCaptionListeners();
updateCustomCaptions();
}
function buildCaptionsMenu() {
if (!captionsMenu) return;
captionsMenu.innerHTML = '';
// Option: Off
const offBtn = document.createElement('button');
offBtn.className = 'captions-menu-option';
offBtn.textContent = 'Off';
offBtn.onclick = () => selectCaption(-1);
captionsMenu.appendChild(offBtn);
// Options: Each track
captionsTracks.forEach((track, idx) => {
const btn = document.createElement('button');
btn.className = 'captions-menu-option';
btn.textContent = track.label || `Track ${idx+1}`;
btn.onclick = () => selectCaption(idx);
captionsMenu.appendChild(btn);
});
updateCaptionsMenuSelection();
}
let selectedCaptionTrackIndex = -1;
function getActiveCueText(track) {
if (!track || !track.activeCues || track.activeCues.length === 0) return '';
return Array.from(track.activeCues)
.map((cue) => cue.text.trim())
.filter(Boolean)
.join('\n');
}
function updateCustomCaptions() {
if (!captionOverlay) return;
const track = video.textTracks[selectedCaptionTrackIndex];
if (!track || selectedCaptionTrackIndex < 0) {
captionOverlay.style.display = 'none';
captionOverlay.textContent = '';
return;
}
const text = getActiveCueText(track);
if (!text) {
captionOverlay.style.display = 'none';
captionOverlay.textContent = '';
return;
}
captionOverlay.innerHTML = text
.split('\n')
.map((line) => `<span>${line}</span>`)
.join('');
captionOverlay.style.display = 'block';
}
function attachCaptionListeners() {
if (!video.textTracks) return;
Array.from(video.textTracks).forEach((tt) => {
if (tt.addEventListener) {
tt.removeEventListener('cuechange', updateCustomCaptions);
tt.addEventListener('cuechange', updateCustomCaptions);
} else {
tt.oncuechange = updateCustomCaptions;
}
});
video.removeEventListener('timeupdate', updateCustomCaptions);
video.addEventListener('timeupdate', updateCustomCaptions);
}
function selectCaption(idx) {
selectedCaptionTrackIndex = idx;
Array.from(video.textTracks).forEach((tt, i) => {
tt.mode = 'hidden';
});
if (idx >= 0 && video.textTracks[idx]) {
video.textTracks[idx].mode = 'hidden';
}
updateCaptionsMenuSelection(idx);
updateCustomCaptions();
}
function updateCaptionsMenuSelection(selectedIdx) {
const options = captionsMenu.querySelectorAll('.captions-menu-option');
options.forEach((opt, i) => {
opt.classList.remove('selected');
});
if (typeof selectedIdx === 'number' && selectedIdx >= 0) {
options[selectedIdx+1].classList.add('selected');
} else if (selectedIdx === -1) {
options[0].classList.add('selected');
} else {
// Default: select the default track
let defIdx = captionsTracks.findIndex(t => t.default);
if (defIdx === -1 && captionsTracks.length) defIdx = 0;
if (defIdx >= 0) options[defIdx+1].classList.add('selected');
}
}
// Re-setup captions if video is reloaded
video.addEventListener('loadedmetadata', setupCaptions);
// --- MEDIA SESSION LOGIC ---
function setupMediaSession() {
if ('mediaSession' in navigator) {
let artworkUrl = '';
if (config.thumbnail_url) {
artworkUrl = /^https?:\/\//i.test(config.thumbnail_url)
? `/proxy-image?url=${encodeURIComponent(config.thumbnail_url)}`
: config.thumbnail_url;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: config.series_title + (config.episode_num !== "?" ? ` - Episode ${config.episode_num}` : ""),
artist: config.series_title,
artwork: artworkUrl ?[{ src: artworkUrl, sizes: '512x512', type: 'image/jpeg' }] :[]
});
navigator.mediaSession.setActionHandler('play', () => video.play());
navigator.mediaSession.setActionHandler('pause', () => video.pause());
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
video.currentTime = Math.max(video.currentTime - (details.seekOffset || 10), 0);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
video.currentTime = Math.min(video.currentTime + (details.seekOffset || 10), video.duration);
});
if (!config.is_movie) {
navigator.mediaSession.setActionHandler('nexttrack', triggerNextEpisode);
}
}
}
// --- 2. PLAYER INITIALIZATION ---
async function initPlayer() {
setupCaptions();
setupMediaSession();
if (!config.video) return;
if (config.full) playerContainer.classList.add("fullscreen-mode");
if (config.thumbnails) loadThumbnails(config.thumbnails);
const saved = loadProgress(config.id, config.episode);
let startAt = 0;
if (saved && saved.timestamp > 0 && saved.state !== "finished") {
startAt = saved.timestamp;
toastNotification.textContent = `Resumed from ${formatTime(startAt)}`;
toastNotification.classList.add("show");
setTimeout(() => toastNotification.classList.remove("show"), 3000);
}
const url = decodeURIComponent(config.video);
const proxyUrl = getProxyUrl(url);
if (config.isStream && Hls.isSupported()) {
class ProxyLoader extends Hls.DefaultConfig.loader {
constructor(cfg) {
super(cfg);
var load = this.load.bind(this);
this.load = function (ctx, cfg, cbs) {
if (config.referer) {
const orig = ctx.url;
ctx.url = `/proxy?url=${encodeURIComponent(
ctx.url
)}&referer=${encodeURIComponent(config.referer)}`;
const origSuccess = cbs.onSuccess;
cbs.onSuccess = function (resp, stats, context) {
resp.url = orig;
origSuccess(resp, stats, context);
};
}
load(ctx, cfg, cbs);
};
}
}
hls = new Hls({
loader: config.referer ? ProxyLoader : Hls.DefaultConfig.loader,
});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
if (data.levels.length > 1) setupQualityMenu(data.levels);
if (startAt > 0) video.currentTime = startAt;
attemptAutoplay();
});
} else if (url.toLowerCase().endsWith(".mkv")) {
playerContainer.classList.add("processing");
loadingText.textContent = "Processing Video...";
try {
if (!ffmpeg) {
ffmpeg = new FFmpeg.FFmpeg();
await ffmpeg.load({
coreURL:
"https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js",
});
}
const data = await FFmpegUtil.fetchFile(proxyUrl);
await ffmpeg.writeFile("input.mkv", data);
await ffmpeg.exec(["-i", "input.mkv", "-c", "copy", "output.mp4"]);
const out = await ffmpeg.readFile("output.mp4");
video.src = URL.createObjectURL(
new Blob([out.buffer], { type: "video/mp4" })
);
playerContainer.classList.remove("processing");
if (startAt > 0) video.currentTime = startAt;
attemptAutoplay();
} catch (e) {
console.error(e);
loadingText.textContent = "Error loading MKV";
}
} else {
video.src = proxyUrl;
if (startAt > 0) video.currentTime = startAt;
attemptAutoplay();
}
}
function attemptAutoplay() {
video.play().catch(() => {
video.muted = true;
updateVolumeUI(0);
video.play();
});
}
function getProxyUrl(u) {
return !config.referer || u.includes("/proxy?")
? u
: `/proxy?url=${encodeURIComponent(u)}&referer=${encodeURIComponent(
config.referer
)}`;
}
// --- 3. SCRUBBING LOGIC (THE FIX) ---
function scrub(e) {
const rect = progressBarContainer.getBoundingClientRect();
let pos = (e.clientX - rect.left) / rect.width;
pos = Math.max(0, Math.min(1, pos));
progressFilled.style.width = `${pos * 100}%`;
return video.duration ? pos * video.duration : 0;
}
progressBarContainer.addEventListener("mousedown", (e) => {
isScrubbing = true;
wasPausedBeforeScrub = video.paused;
video.pause();
const newTime = scrub(e);
currentTimeEl.textContent = formatTime(newTime);
});
document.addEventListener("mousemove", (e) => {
if (isScrubbing) {
e.preventDefault();
const newTime = scrub(e);
currentTimeEl.textContent = formatTime(newTime);
}
});
document.addEventListener("mouseup", (e) => {
if (isScrubbing) {
const newTime = scrub(e);
video.currentTime = newTime;
isScrubbing = false;
if (!wasPausedBeforeScrub) video.play();
}
});
// Simple click without drag
progressBarContainer.addEventListener("click", (e) => {
if (!isScrubbing && video.duration) {
video.currentTime = scrub(e);
}
});
// --- 4. TIME UPDATE & AUTO EVENTS ---
video.addEventListener("timeupdate", () => {
if (isScrubbing) return; // Don't update visual while dragging
const t = video.currentTime;
const d = video.duration;
if (!d) return;
currentTimeEl.textContent = formatTime(t);
totalTimeEl.textContent = formatTime(d);
progressFilled.style.width = `${(t / d) * 100}%`;
if (video.buffered.length > 0) {
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= t && video.buffered.end(i) >= t) {
bufferedBar.style.width = `${(video.buffered.end(i) / d) * 100}%`;
break;
}
}
}
// AUTO LOGIC
let showSkip = false;
let skipTarget = 0;
let skipLabel = "Skip";
let currentState = "ongoing";
let isOutro = false;
// Check Intro
if (
skipTimes &&
skipTimes.intro &&
t >= skipTimes.intro[0] &&
t < skipTimes.intro[1]
) {
if (autoSkip) {
video.currentTime = skipTimes.intro[1];
toastNotification.textContent = "Skipped Intro";
toastNotification.classList.add("show");
setTimeout(() => toastNotification.classList.remove("show"), 2000);
} else {
showSkip = true;
skipTarget = skipTimes.intro[1];
skipLabel = "Skip Intro";
}
}
// Check Outro
if (skipTimes && skipTimes.outro && t >= skipTimes.outro[0]) {
if (t < skipTimes.outro[1]) {
if (autoSkip) {
video.currentTime = skipTimes.outro[1];
toastNotification.textContent = "Skipped Outro";
toastNotification.classList.add("show");
setTimeout(
() => toastNotification.classList.remove("show"),
2000
);
} else {
showSkip = true;
skipTarget = skipTimes.outro[1];
skipLabel = "Skip Outro";
}
}
isOutro = true;
}
// Next Episode Logic
if (!config.is_movie) {
// Trigger conditions
if (isOutro || (!skipTimes && t / d > 0.95)) {
if (autoNext) {
triggerNextEpisode(); // Just go next immediately
} else {
nextEpOverlayBtn.classList.add("visible");
}
currentState = "finished";
} else {
nextEpOverlayBtn.classList.remove("visible");
}
} else {
if (t / d > 0.95) currentState = "finished";
}
// Show/Hide Manual Skip Button
if (showSkip) {
skipBtn.classList.add("visible");
skipBtn.innerHTML = `${skipLabel} <i class="fas fa-forward"></i>`;
skipBtn.onclick = () => (video.currentTime = skipTarget);
} else {
skipBtn.classList.remove("visible");
}
if (Math.floor(t) % 2 === 0)
saveProgress(config.id, config.episode, t, currentState);
});
// --- 5. CHROMECAST INTEGRATION ---
window["__onGCastApiAvailable"] = function (isAvailable) {
if (isAvailable) {
initCast();
}
};
function initCast() {
cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId:
chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
castBtn.classList.add("available");
castBtn.addEventListener("click", () => {
cast.framework.CastContext.getInstance()
.requestSession()
.then(
() => {
console.log("Cast Session Started");
},
(e) => {
console.log("Cast Error", e);
}
);
});
// Listen for session state to load media
const context = cast.framework.CastContext.getInstance();
context.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(event) => {
if (
event.sessionState === cast.framework.SessionState.SESSION_STARTED
) {
const session = context.getCurrentSession();
const mediaInfo = new chrome.cast.media.MediaInfo(
video.src,
"video/mp4"
); // Adjust content type if HLS
const request = new chrome.cast.media.LoadRequest(mediaInfo);
request.currentTime = video.currentTime;
session.loadMedia(request).then(
() => {
console.log("Media Loaded");
},
(e) => {
console.error("Load Error", e);
}
);
}
}
);
}
// --- 6. UTILITIES (Volume, Playback, Thumbnails, etc) ---
function saveProgress(id, ep, time, state) {
if (!id || !ep) return;
const hist = JSON.parse(localStorage.getItem("animex_watch_history") || "{}");
if (!hist[id]) hist[id] = {};
hist[id][ep] = {
timestamp: time,
state: state,
last_watched: new Date().toISOString(),
};
localStorage.setItem("animex_watch_history", JSON.stringify(hist));
if (window.parent && window !== window.parent)
window.parent.postMessage(
{
type: "animex-progress",
malId: id,
episode: ep,
timestamp: time,
state: state,
duration: video.duration,
},
"*"
);
}
function loadProgress(id, ep) {
const hist = JSON.parse(localStorage.getItem("animex_watch_history") || "{}");
return hist[id] && hist[id][ep] ? hist[id][ep] : null;
}
function triggerNextEpisode() {
if (window.parent && window !== window.parent) {
window.parent.postMessage({ type: "animex-next-episode" }, "*");
} else {
const params = new URLSearchParams(window.location.search);
const ep = parseInt(params.get("episode"), 10);
if (!isNaN(ep)) {
params.set("episode", ep + 1);
window.location.search = params.toString();
} else {
window.location.reload();
}
}
}
// Bind Next Episode buttons
nextEpControlBtn.addEventListener("click", triggerNextEpisode);
nextEpOverlayBtn.addEventListener("click", triggerNextEpisode);
replayBtn.addEventListener("click", () => {
finishedOverlay.classList.remove("visible");
video.currentTime = 0;
video.play();
});
// --- Show buffering spinner when buffering ---
video.addEventListener("waiting", () => {
playerContainer.classList.add("loading");
});
video.addEventListener("playing", () => {
playerContainer.classList.remove("loading");
});
video.addEventListener("seeking", () => {
playerContainer.classList.add("loading");
});
video.addEventListener("seeked", () => {
playerContainer.classList.remove("loading");
});
// --- Show intro/outro markers above progress bar ---
function updateSkipMarkers() {
const d = video.duration;
const intro = skipTimes && skipTimes.intro;
const outro = skipTimes && skipTimes.outro;
const introMarker = document.getElementById("introMarker");
const outroMarker = document.getElementById("outroMarker");
if (intro && d) {
introMarker.style.display = "block";
introMarker.style.left = ((intro[0] / d) * 100) + "%";
introMarker.style.width = (((intro[1] - intro[0]) / d) * 100) + "%";
} else if (introMarker) {
introMarker.style.display = "none";
}
if (outro && d) {
outroMarker.style.display = "block";
outroMarker.style.left = ((outro[0] / d) * 100) + "%";
outroMarker.style.width = (((outro[1] - outro[0]) / d) * 100) + "%";
} else if (outroMarker) {
outroMarker.style.display = "none";
}
}
video.addEventListener("loadedmetadata", updateSkipMarkers);
video.addEventListener("durationchange", updateSkipMarkers);
function formatTime(s) {
if (isNaN(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60)
.toString()
.padStart(2, "0");
return `${m}:${sec}`;
}
function togglePlay() {
if (finishedOverlay.classList.contains("visible")) return;
if (video.paused) video.play();
else video.pause();
}
// Play/Pause Events
video.addEventListener("play", () => {
playPauseBtn.innerHTML = '<i class="fas fa-pause"></i>';
playerContainer.classList.remove("paused");
document.getElementById("centerStatusIcon").innerHTML =
'<i class="fas fa-play"></i>';
animateStatus();
startControlsTimer();
});
video.addEventListener("click", () => {
togglePlay();
});
video.addEventListener("pause", () => {
playPauseBtn.innerHTML = '<i class="fas fa-play"></i>';
playerContainer.classList.add("paused");
document.getElementById("centerStatusIcon").innerHTML =
'<i class="fas fa-pause"></i>';
animateStatus();
playerContainer.classList.add("controls-active");
});
playPauseBtn.addEventListener("click", togglePlay);
// Volume
const VOL_KEY = "animex_volume";
let lastVolume = parseFloat(localStorage.getItem(VOL_KEY) || "1");
video.volume = lastVolume;
updateVolumeUI(lastVolume);
function updateVolumeUI(vol) {
volumeSlider.value = vol;
volumeSlider.style.backgroundSize = `${vol * 100}% 100%`;
volumeBtn.innerHTML =
vol === 0 || video.muted
? '<i class="fas fa-volume-mute"></i>'
: vol < 0.5
? '<i class="fas fa-volume-down"></i>'
: '<i class="fas fa-volume-up"></i>';
}
volumeBtn.addEventListener("click", () => {
if (video.volume > 0) {
lastVolume = video.volume;
video.volume = 0;
} else {
video.volume = lastVolume > 0 ? lastVolume : 1;
}
updateVolumeUI(video.volume);
});
volumeSlider.addEventListener("input", (e) => {
video.volume = e.target.value;
video.muted = video.volume === 0;
updateVolumeUI(video.volume);
localStorage.setItem(VOL_KEY, video.volume);
});
// Settings & Captions Menus Interactions
if (captionsMenuBtn) {
captionsMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
captionsMenu.classList.toggle('active');
captionsMenuBtn.classList.toggle('active');
// Close settings if open
if (settingsMenu) {
settingsMenu.classList.remove('active');
settingsMenu.classList.remove('show-speed', 'show-quality');
}
});
}
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.classList.toggle("active");
settingsMenu.classList.remove("show-speed", "show-quality");
// Close captions if open
if (captionsMenu) {
captionsMenu.classList.remove('active');
captionsMenuBtn.classList.remove('active');
}
});
document.body.addEventListener("click", (e) => {
if (settingsMenu && settingsMenu.classList.contains("active") && !document.getElementById("settingsContainer").contains(e.target)) {
settingsMenu.classList.remove("active");
settingsMenu.classList.remove("show-speed", "show-quality");
}
if (captionsMenu && captionsMenu.classList.contains("active") && !document.getElementById("captionsContainer").contains(e.target)) {
captionsMenu.classList.remove("active");
captionsMenuBtn.classList.remove("active");
}
});
// Speed
speedMenuTrigger.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.classList.add("show-speed");
});
document
.getElementById("closeSpeedMenu")
.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.classList.remove("show-speed");
});
document
.querySelectorAll(".speed-panel .submenu-option")
.forEach((opt) => {
opt.addEventListener("click", () => {
video.playbackRate = parseFloat(opt.dataset.speed);
document.getElementById("currentSpeedVal").textContent =
opt.textContent;
document
.querySelectorAll(".speed-panel .submenu-option")
.forEach((o) => o.classList.remove("selected"));
opt.classList.add("selected");
settingsMenu.classList.remove("active");
});
});
// Quality
function setupQualityMenu(levels) {
qualityMenuTrigger.style.display = "flex";
const list = document.getElementById("qualityList");
while (list.children.length > 1) list.removeChild(list.lastChild);
const autoDiv = document.createElement("div");
autoDiv.className = "submenu-option selected";
autoDiv.textContent = "Auto";
autoDiv.onclick = () => {
hls.currentLevel = -1;
document.getElementById("currentQualityVal").textContent = "Auto";
updateQualitySelection(autoDiv);
};
list.appendChild(autoDiv);
levels.forEach((lvl, idx) => {
const d = document.createElement("div");
d.className = "submenu-option";
d.textContent = `${lvl.height}p`;
d.onclick = () => {
hls.currentLevel = idx;
document.getElementById(
"currentQualityVal"
).textContent = `${lvl.height}p`;
updateQualitySelection(d);
};
list.appendChild(d);
});
}
function updateQualitySelection(el) {
document
.querySelectorAll("#qualityList .submenu-option")
.forEach((o) => o.classList.remove("selected"));
el.classList.add("selected");
settingsMenu.classList.remove("active");
}
qualityMenuTrigger.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.classList.add("show-quality");
});
document
.getElementById("closeQualityMenu")
.addEventListener("click", (e) => {
e.stopPropagation();
settingsMenu.classList.remove("show-quality");
});
fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
if (playerContainer.requestFullscreen)
playerContainer.requestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen();
} else document.exitFullscreen();
});
// Keyboard & Mouse
document.addEventListener("keydown", (e) => {
if (document.activeElement.tagName === "INPUT") return;
const k = e.key.toLowerCase();
if ([" ", "k"].includes(k)) {
e.preventDefault();
togglePlay();
}
if (["f"].includes(k)) fullscreenBtn.click();
if (["m"].includes(k)) volumeBtn.click();
if (["arrowleft"].includes(k)) {
e.preventDefault();
video.currentTime -= 10;
animateSeek(document.getElementById("seekFeedbackRewind"));
}
if (["arrowright"].includes(k)) {
e.preventDefault();
video.currentTime += 10;
animateSeek(document.getElementById("seekFeedbackForward"));
}
});
// Double Tap Update (More Reliable)
let tapTimeoutLeft = null;
document.getElementById("seekOverlayLeft").addEventListener("click", (e) => {
e.stopPropagation();
if (tapTimeoutLeft) {
clearTimeout(tapTimeoutLeft);
tapTimeoutLeft = null;
video.currentTime = Math.max(0, video.currentTime - 10);
animateSeek(document.getElementById("seekFeedbackRewind"));
} else {
tapTimeoutLeft = setTimeout(() => {
tapTimeoutLeft = null;
if (isMobile) togglePlay();
}, 300);
}
});
let tapTimeoutRight = null;
document.getElementById("seekOverlayRight").addEventListener("click", (e) => {
e.stopPropagation();
if (tapTimeoutRight) {
clearTimeout(tapTimeoutRight);
tapTimeoutRight = null;
video.currentTime = Math.min(video.duration, video.currentTime + 10);
animateSeek(document.getElementById("seekFeedbackForward"));
} else {
tapTimeoutRight = setTimeout(() => {
tapTimeoutRight = null;
if (isMobile) togglePlay();
}, 300);
}
});
// UI Helpers
function showControls() {
playerContainer.classList.add("controls-active");
startControlsTimer();
}
function startControlsTimer() {
clearTimeout(controlsTimeout);
if (!video.paused)
controlsTimeout = setTimeout(() => {
if (!settingsMenu.classList.contains("active") && !captionsMenu.classList.contains("active"))
playerContainer.classList.remove("controls-active");
}, 3000);
}
playerContainer.addEventListener("mousemove", showControls);
playerContainer.addEventListener("touchstart", showControls, {
passive: true,
});
function animateSeek(el) {
el.classList.remove("animate");
void el.offsetWidth;
el.classList.add("animate");
}
function animateStatus() {
const el = document.getElementById("centerStatusIcon");
el.classList.remove("animate");
void el.offsetWidth;
el.classList.add("animate");
}
// --- THUMBNAIL LOGIC ---
let thumbnailCues =[];
async function loadThumbnails(thumbnails) {
let vttUrl = "";
// Handle thumbnails config as string or array
if (typeof thumbnails === 'string') {
vttUrl = thumbnails;
} else if (Array.isArray(thumbnails)) {
const thumbData = thumbnails.find((t) => t.kind === "thumbnails");
if (thumbData) vttUrl = thumbData.file;
}
if (!vttUrl) return;
try {
// If the vtt is behind a proxy query, grab the original root so we know where the image is
let originalVttUrl = vttUrl;
if (vttUrl.includes('url=')) {
try {
const urlParams = new URLSearchParams(vttUrl.split('?')[1]);
originalVttUrl = urlParams.get('url') || vttUrl;
} catch(e) {}
}
const baseOriginalUrl = originalVttUrl.substring(0, originalVttUrl.lastIndexOf('/') + 1);
// Force proxy for VTT fetch if it's external to prevent CORS errors
let fetchUrl = vttUrl;
if (/^https?:\/\//i.test(vttUrl) && !vttUrl.includes('/proxy')) {
fetchUrl = `/proxy?url=${encodeURIComponent(vttUrl)}`;
if (config.referer) fetchUrl += `&referer=${encodeURIComponent(config.referer)}`;
}
const response = await fetch(fetchUrl);
const text = await response.text();
// Simple VTT Parser for sprites
// Format: 00:00:00.000 --> 00:00:05.000 \n url#xywh=x,y,w,h
const lines = text.split("\n");
let currentCue = null;
for (let line of lines) {
line = line.trim();
if (line.includes("-->")) {
const times = line.split("-->");
if (times.length === 2) {
currentCue = {
start: parseVTTTime(times[0].trim()),
end: parseVTTTime(times[1].trim()),
};
}
} else if (line.includes("#xywh=") && currentCue) {
const parts = line.split("#xywh=");
let imgUrl = parts[0];
const coords = parts[1].split(",");
// Handle relative URLs (append original base path)
if (!imgUrl.startsWith("http")) {
imgUrl = baseOriginalUrl + imgUrl;
}
// Proxy remote thumbnail images
if (/^https?:\/\//i.test(imgUrl)) {
imgUrl = `/proxy-image?url=${encodeURIComponent(imgUrl)}`;
}
currentCue.img = imgUrl;
currentCue.x = parseInt(coords[0]);
currentCue.y = parseInt(coords[1]);
currentCue.w = parseInt(coords[2]);
currentCue.h = parseInt(coords[3]);
thumbnailCues.push(currentCue);
currentCue = null;
}
}
} catch (e) {
console.error("Failed to load thumbnails", e);
}
}
function parseVTTTime(timestamp) {
const parts = timestamp.split(":");
let seconds = 0;
if (parts.length === 3) {
// HH:MM:SS.ms
seconds += parseFloat(parts[0]) * 3600;
seconds += parseFloat(parts[1]) * 60;
seconds += parseFloat(parts[2]);
} else if (parts.length === 2) {
// MM:SS.ms
seconds += parseFloat(parts[0]) * 60;
seconds += parseFloat(parts[1]);
}
return seconds;
}
// Add Hover Listener to Progress Bar
progressBarContainer.addEventListener("mousemove", (e) => {
if (thumbnailCues.length === 0 || !video.duration) return;
const rect = progressBarContainer.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const hoverTime = percent * video.duration;
// Find the matching cue
const cue = thumbnailCues.find(
(c) => hoverTime >= c.start && hoverTime < c.end
);
const tooltip = document.getElementById("thumbnailTooltip");
const thumbImg = document.getElementById("thumbnailImage");
const thumbTime = document.getElementById("thumbnailTime");
if (cue) {
tooltip.style.display = "flex";
// Calculate Position (keep tooltip inside video bounds)
let leftPos = e.clientX - rect.left;
const minX = (cue.w || 180) / 2; // half width approx
const maxX = rect.width - minX;
if (leftPos < minX) leftPos = minX;
if (leftPos > maxX) leftPos = maxX;
tooltip.style.left = `${leftPos}px`;
thumbTime.textContent = formatTime(hoverTime);
// Update CSS for Sprite
thumbImg.style.width = `${cue.w}px`;
thumbImg.style.height = `${cue.h}px`;
thumbImg.style.backgroundImage = `url('${cue.img}')`;
thumbImg.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
} else {
tooltip.style.display = "none";
}
});
progressBarContainer.addEventListener("mouseleave", () => {
document.getElementById("thumbnailTooltip").style.display = "none";
});
// Start
initPlayer();
</script>
</body>
</html>