Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c37df755 | |||
| 6a9d3b25bf | |||
| 75b98de746 | |||
| eaf48276f0 | |||
| 2e3918def4 |
Generated
+373
-454
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,7 +25,7 @@
|
||||
"chart.js": "^4.5.0",
|
||||
"dashjs": "^5.0.0",
|
||||
"hammerjs": "^2.0.8",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr": "^3.8.4",
|
||||
"tw-elements": "^1.1.0",
|
||||
"vidstack": "^1.12.13"
|
||||
}
|
||||
|
||||
+1
-66
@@ -1,4 +1,5 @@
|
||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||
@import './player.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -8,47 +9,6 @@
|
||||
--breakpoint-xs: 30rem;
|
||||
}
|
||||
|
||||
/* Player */
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded="true"] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Player Engagement Heatmap, overlays the progress bar track */
|
||||
.plyr__progress__heatmap {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 40%;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.plyr__progress__heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Player Ambient */
|
||||
.decoy {
|
||||
position: absolute;
|
||||
@@ -68,31 +28,6 @@ input:checked~.dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
#plyr__time_skip {
|
||||
background: #c61e54;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
left: 50%;
|
||||
min-width: 60px;
|
||||
width: min-content;
|
||||
max-width: 100px;
|
||||
max-height: 90px;
|
||||
opacity: 0;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
transform: translate(-50%, -50%);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transition: 1s;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-shadow: 0px 0px 45px #000000;
|
||||
}
|
||||
|
||||
/* DL Button Glow */
|
||||
.hover\:glow:hover {
|
||||
filter: drop-shadow(0px 0px 7px rgba(255, 29, 72, 0.5));
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
/* ============================================================
|
||||
HStream Custom Video Player
|
||||
Dark-theme-first with rose-red accents (#c61e54)
|
||||
============================================================ */
|
||||
|
||||
/* ---- Player Wrapper ---- */
|
||||
.hstream-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
direction: ltr;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hstream-player * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- Video Element ---- */
|
||||
.hstream-player__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* ---- Poster Overlay ---- */
|
||||
.hstream-player__poster {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__poster--hidden {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ---- Large Play Overlay ---- */
|
||||
.hstream-player__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__play-overlay--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(198, 30, 84, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.15s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 0 30px rgba(198, 30, 84, 0.4), 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(225, 29, 72, 0.9);
|
||||
box-shadow: 0 0 40px rgba(198, 30, 84, 0.6), 0 4px 25px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__play-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ---- Controls Container ---- */
|
||||
.hstream-player__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: linear-gradient(to top, rgba(10, 10, 10, 0.92), rgba(10, 10, 10, 0.75) 60%, transparent);
|
||||
padding: 40px 12px 10px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.hstream-player__controls--hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Progress Section (full width row) ---- */
|
||||
.hstream-player__progress-wrapper {
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: height 0.15s ease, margin-top 0.15s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover,
|
||||
.hstream-player__progress--dragging {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hstream-player__progress--dragging .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.hstream-player__progress-buffer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transition: width 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hstream-player__progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(to right, #c61e54, #e11d48);
|
||||
transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress-heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #c61e54;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.15s ease;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 8px rgba(198, 30, 84, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__progress:hover .hstream-player__progress-thumb,
|
||||
.hstream-player__progress-wrapper--active .hstream-player__progress-thumb {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* ---- Time Tooltip ---- */
|
||||
.hstream-player__time-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
color: #fff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hstream-player__time-tooltip--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Thumbnail Preview ---- */
|
||||
.hstream-player__thumbnail-preview {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 16px);
|
||||
transform: translateX(-50%);
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 5;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-img {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview-time {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.hstream-player__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease, transform 0.1s ease, color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hstream-player__button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.hstream-player__button--active {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ---- Time Display ---- */
|
||||
.hstream-player__time-display {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.hstream-player__time-separator {
|
||||
opacity: 0.5;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* ---- Volume Wrapper ---- */
|
||||
.hstream-player__volume-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
margin: -6px 0;
|
||||
}
|
||||
|
||||
.hstream-player__volume-wrapper:hover .hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.hstream-player--mobile .hstream-player__volume-slider-container {
|
||||
width: 80px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
outline: none !important;
|
||||
cursor: pointer !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
margin-top: -5px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 4px !important;
|
||||
border-radius: 2px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 13px !important;
|
||||
height: 13px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #c61e54 !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
/* ---- Spacer ---- */
|
||||
.hstream-player__spacer {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
/* ---- Settings Menu ---- */
|
||||
.hstream-player__menu-container {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 80px;
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
background: rgba(20, 20, 20, 0.70);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.hstream-player__menu-container--open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__menu-panel--active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-back i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item-radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio {
|
||||
border-color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-item-radio::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-value {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item > span:first-of-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-item--checked .hstream-player__menu-value {
|
||||
color: #c61e54;
|
||||
}
|
||||
|
||||
.hstream-player__menu-badge {
|
||||
display: inline-block;
|
||||
background: #c61e54;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hstream-player__menu-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ---- Skip Overlay (Mobile) ---- */
|
||||
.hstream-player__skip-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(198, 30, 84, 0.7);
|
||||
border-radius: 50%;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay--visible {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ---- Loading Spinner ---- */
|
||||
.hstream-player__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hstream-player__loading--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hstream-player__spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: #c61e54;
|
||||
border-radius: 50%;
|
||||
animation: hstream-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hstream-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- Player Switcher Toggle ---- */
|
||||
.player-switcher {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(10, 10, 10, 0.65);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
padding: 6px 4px 6px 14px;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.aspect-\[16\/9\]:hover .player-switcher {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.player-switcher--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.player-switcher__label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.player-switcher__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-switcher__btn:hover {
|
||||
background: rgba(198, 30, 84, 0.2);
|
||||
border-color: rgba(198, 30, 84, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__btn i {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.player-switcher__btn--active {
|
||||
background: rgba(198, 30, 84, 0.25);
|
||||
border-color: rgba(198, 30, 84, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-switcher__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #c61e54;
|
||||
box-shadow: 0 0 6px rgba(198, 30, 84, 0.6);
|
||||
}
|
||||
|
||||
/* ---- Fullscreen (remove rounded borders) ---- */
|
||||
.hstream-player:fullscreen,
|
||||
.hstream-player:-webkit-full-screen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player:fullscreen .hstream-player__video,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__video,
|
||||
.hstream-player:fullscreen .hstream-player__poster,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__poster,
|
||||
.hstream-player:fullscreen .hstream-player__controls,
|
||||
.hstream-player:-webkit-full-screen .hstream-player__controls {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- Idle State (hide controls & cursor) ---- */
|
||||
.hstream-player--idle {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.hstream-player--idle .hstream-player__controls {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Mobile Responsive ---- */
|
||||
@media (max-width: 767px) {
|
||||
.hstream-player {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__controls {
|
||||
padding: 36px 4px 8px 4px;
|
||||
gap: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hstream-player__time-display {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__play-btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hstream-player__menu-container {
|
||||
right: 8px;
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-wrapper {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.hstream-player__progress {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hstream-player__progress-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hstream-player__skip-overlay i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hstream-player__thumbnail-preview {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider-container {
|
||||
width: 60px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider {
|
||||
accent-color: #c61e54 !important;
|
||||
width: 56px !important;
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#c61e54 0%,
|
||||
#c61e54 var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) var(--volume-pct, 50%),
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-webkit-slider-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-track {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-progress {
|
||||
height: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: #c61e54 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.hstream-player__volume-slider::-moz-range-thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.hstream-player__time-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hstream-player__button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Plyr Player Overrides — pink accent, rounded corners, skip overlay
|
||||
============================================================ */
|
||||
.plyr--full-ui input[type='range'] {
|
||||
color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control.plyr__tab-focus,
|
||||
.plyr--video .plyr__control:hover,
|
||||
.plyr--video .plyr__control[aria-expanded='true'] {
|
||||
background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr__menu__container .plyr__control[role='menuitemradio'][aria-checked='true']::before {
|
||||
background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #c61e54))) !important;
|
||||
}
|
||||
|
||||
.plyr--full-ui {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
/* Plyr Engagement Heatmap, overlays the progress bar track */
|
||||
.plyr__progress__heatmap {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 40%;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.plyr__progress__heatmap-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Plyr Mobile Double-tap Skip Overlay */
|
||||
#plyr__time_skip {
|
||||
background: #c61e54;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
left: 50%;
|
||||
min-width: 60px;
|
||||
width: min-content;
|
||||
max-width: 100px;
|
||||
max-height: 90px;
|
||||
opacity: 0;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
transform: translate(-50%, -50%);
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transition: 1s;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
box-shadow: 0px 0px 45px #000000;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// Plyr Fallback Player
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
import * as dashjs from 'dashjs';
|
||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||
|
||||
import { initMobileWidescreen } from './player-mobile';
|
||||
import { mobileDoubleClick } from './player-mobile';
|
||||
import { playNextPlaylistVideo } from './playlist';
|
||||
import { addVideoTracks } from './player-data';
|
||||
import { addSubtitleTracks } from './player-data';
|
||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||
import { isIOS } from './detect-ios';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||
import { renderHeatmap } from './player/player-heatmap';
|
||||
|
||||
var player = null;
|
||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||
var dashSupported = dashjs.supportsMediaSource();
|
||||
var apiResponse = {};
|
||||
var volume = 0.5;
|
||||
var muted = false;
|
||||
var captions = true;
|
||||
var lastTime = 0.0;
|
||||
var streamServer = '';
|
||||
var streamServers = [];
|
||||
var streamServerIndex = 0;
|
||||
var streamServerCount = 0;
|
||||
var ambientMode = true;
|
||||
var serverFallback = false;
|
||||
var saveInterval;
|
||||
var watchTracked = false;
|
||||
var subtitleInstance = null;
|
||||
|
||||
function trackWatchTime() {
|
||||
if (watchTracked) return;
|
||||
var video = document.getElementsByTagName('video')[0];
|
||||
if (video && video.currentTime >= 10) {
|
||||
watchTracked = true;
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
window.axios.post('/watched/track', {
|
||||
episode_id: episodeId
|
||||
}).then(function () {
|
||||
console.log('Watch tracked for episode ' + episodeId);
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to track watch: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var controls = [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'duration',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'fullscreen',
|
||||
];
|
||||
|
||||
if (localStorage.hstreamVolume) {
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamCaptions) {
|
||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamMuted) {
|
||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||
}
|
||||
|
||||
if (localStorage.hstreamServerFallback) {
|
||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||
}
|
||||
|
||||
if (!av1Supported) {
|
||||
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function initDash(data, player) {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(video, el.src, true);
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCanvasDimension(canvas, video) {
|
||||
canvas.height = video.offsetHeight;
|
||||
canvas.width = video.offsetWidth;
|
||||
}
|
||||
|
||||
function paintStaticVideo(ctx, video) {
|
||||
if (localStorage.theme == 'light') {
|
||||
return;
|
||||
}
|
||||
if (!ambientMode) {
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||
}
|
||||
|
||||
function toggleAmbientMode() {
|
||||
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||
if (ambientMode) {
|
||||
ambientMode = false;
|
||||
localStorage.ambientMode = 'false';
|
||||
setCanvasDimension(canvas, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||
} else {
|
||||
ambientMode = true;
|
||||
localStorage.ambientMode = 'true';
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAsiaServer() {
|
||||
if (serverFallback) {
|
||||
serverFallback = false;
|
||||
localStorage.hstreamServerFallback = 'false';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||
streamServers = apiResponse.stream_domains;
|
||||
} else {
|
||||
serverFallback = true;
|
||||
localStorage.hstreamServerFallback = 'true';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
}
|
||||
|
||||
function initSubtitles(lang) {
|
||||
if (isIOS()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
subtitleInstance.dispose();
|
||||
}
|
||||
|
||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
|
||||
if (lang != 'en') {
|
||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||
} else {
|
||||
newSubUrl += 'eng.ass';
|
||||
}
|
||||
|
||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
if (lang == 'hi') {
|
||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||
}
|
||||
|
||||
var options = {
|
||||
video: document.getElementsByTagName('video')[0],
|
||||
subUrl: newSubUrl,
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||
fonts: [subFont],
|
||||
renderMode: 'wasm-blend',
|
||||
};
|
||||
|
||||
subtitleInstance = new SubtitlesOctopus(options);
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
player = new Plyr('#player', {
|
||||
controls,
|
||||
quality: {
|
||||
default: 720,
|
||||
options: [2161, 2160, 1081, 1080, 720]
|
||||
},
|
||||
i18n: {
|
||||
qualityLabel: {
|
||||
2161: '2160p48',
|
||||
2160: '2160p',
|
||||
1081: '1080p48',
|
||||
1080: '1080p',
|
||||
720: '720p'
|
||||
},
|
||||
qualityBadge: {
|
||||
2161: 'UHD@48',
|
||||
1081: 'FHD@48',
|
||||
1080: 'FHD',
|
||||
},
|
||||
},
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||
});
|
||||
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
|
||||
player.source = {
|
||||
type: 'video',
|
||||
title: apiResponse.title,
|
||||
poster: apiResponse.poster,
|
||||
previewThumbnails: {
|
||||
enabled: true,
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||
},
|
||||
sources: data,
|
||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||
};
|
||||
|
||||
player.volume = volume;
|
||||
player.muted = muted;
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
});
|
||||
|
||||
initDash(data, player);
|
||||
}
|
||||
|
||||
let canvas = document.getElementById('ambientVideo'), ctx = canvas.getContext('2d'), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log('Stopped video, because user didn\'t click play.');
|
||||
}
|
||||
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
startEngagementTracking(episodeId);
|
||||
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
player.on('seeked', () => {
|
||||
paintStaticVideo(ctx, video);
|
||||
if (player.currentTime > 0) {
|
||||
lastTime = player.currentTime;
|
||||
}
|
||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
if (player.paused) {
|
||||
paintStaticVideo(ctx, video);
|
||||
}
|
||||
});
|
||||
|
||||
player.on('captionsenabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
||||
localStorage.setItem('hstreamCaptions', 'true');
|
||||
console.log('Set Captions Status to Local Storage: true');
|
||||
});
|
||||
|
||||
player.on('captionsdisabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
||||
localStorage.setItem('hstreamCaptions', 'false');
|
||||
console.log('Set Captions Status to Local Storage: false');
|
||||
});
|
||||
|
||||
player.on('volumechange', () => {
|
||||
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||
});
|
||||
|
||||
player.on('ended', () => {
|
||||
playNextPlaylistVideo();
|
||||
});
|
||||
|
||||
player.on('timeupdate', () => {
|
||||
trackWatchTime();
|
||||
});
|
||||
|
||||
player.on('languagechange', (event) => {
|
||||
let lang = event.detail.plyr.captions.language;
|
||||
|
||||
console.log('Subtitle Event ' + lang);
|
||||
initSubtitles(lang);
|
||||
});
|
||||
|
||||
function playerPlayTemp() {
|
||||
clickedPlay = true;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
||||
play.addEventListener('click', playerPlayTemp)
|
||||
);
|
||||
|
||||
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
||||
|
||||
initMobileWidescreen();
|
||||
|
||||
setTimeout(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const time = parseInt(params.get('t'));
|
||||
if (!isNaN(time)) {
|
||||
player.currentTime = time;
|
||||
console.log('Skipping to ' + time);
|
||||
}
|
||||
if (lastTime > 0) {
|
||||
player.currentTime = lastTime;
|
||||
console.log('Skipping to ' + lastTime);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
player.on('ready', () => {
|
||||
mobileDoubleClick(player);
|
||||
|
||||
const video = document.querySelector('video');
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
if (video && video.duration) {
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
} else if (video) {
|
||||
video.addEventListener('loadedmetadata', function onMeta() {
|
||||
video.removeEventListener('loadedmetadata', onMeta);
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
||||
if (settingElements.length == 3) {
|
||||
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
||||
|
||||
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
||||
if (settingNodes.length == 4) {
|
||||
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
||||
}
|
||||
|
||||
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
||||
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
||||
let serverSelects = document.getElementsByClassName('change_server');
|
||||
for (let i = 0; i < serverSelects.length; i++) {
|
||||
serverSelects[i].addEventListener('click', function () {
|
||||
streamServerIndex = Number(this.value);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveInterval = setInterval(function () {
|
||||
lastTime = player.currentTime;
|
||||
console.log('Last Player Position: ' + lastTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
export function initPlyrPlayer(episodeId) {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: episodeId
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
initPlayer();
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
return player;
|
||||
}
|
||||
+190
-386
@@ -1,30 +1,17 @@
|
||||
// Plyr Player
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
// Vidstack Player
|
||||
// HStream Custom Video Player
|
||||
import 'vidstack/player/styles/default/theme.css';
|
||||
import 'vidstack/player/styles/default/layouts/video.css';
|
||||
import { VidstackPlayer, VidstackPlayerLayout } from 'vidstack/global/player';
|
||||
|
||||
// Dash Support
|
||||
import * as dashjs from 'dashjs';
|
||||
|
||||
// Subtitle Support
|
||||
import SubtitlesOctopus from '@jellyfin/libass-wasm';
|
||||
|
||||
// Custom JS
|
||||
import { initMobileWidescreen } from './player-mobile';
|
||||
import { mobileDoubleClick } from './player-mobile'
|
||||
import { HStreamPlayer } from './player/player-core';
|
||||
import { initMobileWidescreen, initMobileDoubleTap, isMobile } from './player/player-mobile';
|
||||
import { playNextPlaylistVideo } from './playlist';
|
||||
import { addVideoTracks } from './player-data';
|
||||
import { addSubtitleTracks } from './player-data';
|
||||
import { serverSelectMenuItem, serverSelectSubmenu, serverSelectMenuClickToggle } from './player-server-select';
|
||||
import { addVideoTracks, addSubtitleTracks } from './player/player-data';
|
||||
import { isIOS } from './detect-ios';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player-engagement';
|
||||
import { renderHeatmap } from './player-heatmap';
|
||||
import { startEngagementTracking, stopEngagementTracking } from './player/player-engagement';
|
||||
import { renderHeatmap } from './player/player-heatmap';
|
||||
|
||||
// Variables
|
||||
var player = null;
|
||||
var av1Supported = (!!document.createElement('video').canPlayType('video/webm; codecs="av01.0.05M.08, opus"'));
|
||||
var dashSupported = dashjs.supportsMediaSource();
|
||||
@@ -35,23 +22,23 @@ var captions = true;
|
||||
var lastTime = 0.0;
|
||||
var streamServer = '';
|
||||
var streamServers = [];
|
||||
var fallbackServers = [];
|
||||
var streamServerIndex = 0;
|
||||
var streamServerCount = 0;
|
||||
var ambientMode = true;
|
||||
var serverFallback = false;
|
||||
var saveInterval;
|
||||
var watchTracked = false;
|
||||
var subtitleInstance = null;
|
||||
|
||||
// Track that the user watched at least 10 seconds of the video
|
||||
function trackWatchTime() {
|
||||
if (watchTracked) return;
|
||||
var video = document.getElementsByTagName('video')[0];
|
||||
if (video && video.currentTime >= 10) {
|
||||
var videoEl = document.getElementsByTagName('video')[0];
|
||||
if (videoEl && videoEl.currentTime >= 10) {
|
||||
watchTracked = true;
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
window.axios.post('/watched/track', {
|
||||
episode_id: episodeId
|
||||
}).then(function (response) {
|
||||
}).then(function () {
|
||||
console.log('Watch tracked for episode ' + episodeId);
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to track watch: ' + error);
|
||||
@@ -59,119 +46,24 @@ function trackWatchTime() {
|
||||
}
|
||||
}
|
||||
|
||||
var subtitleInstance = null;
|
||||
|
||||
var controls = [
|
||||
'play-large', // The large play button in the center
|
||||
'play', // Play/pause playback
|
||||
'progress', // The progress bar and scrubber for playback and buffering
|
||||
'current-time', // The current time of playback
|
||||
'duration', // The full duration of the media
|
||||
'mute', // Toggle mute
|
||||
'volume', // Volume control
|
||||
'captions', // Toggle captions
|
||||
'settings', // Settings menu
|
||||
'fullscreen', // Toggle fullscreen
|
||||
];
|
||||
|
||||
// Load Volume from LocalStorage
|
||||
if (localStorage.hstreamVolume) {
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume')).toFixed(2);
|
||||
volume = parseFloat(localStorage.getItem('hstreamVolume'));
|
||||
if (!isNaN(volume)) volume = Math.max(0, Math.min(1, volume));
|
||||
console.log('Loaded Audio Volume from Local Storage: ' + volume);
|
||||
}
|
||||
|
||||
// Load Captions from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
captions = (localStorage.getItem('hstreamCaptions') == 'true');
|
||||
captions = (localStorage.getItem('hstreamCaptions') === 'true');
|
||||
console.log('Loaded Captions Status from Local Storage: ' + captions);
|
||||
}
|
||||
|
||||
// Load Muted from LocalStorage
|
||||
if (localStorage.hstreamCaptions) {
|
||||
muted = (localStorage.getItem('hstreamMuted') == 'true');
|
||||
if (localStorage.hstreamMuted) {
|
||||
muted = (localStorage.getItem('hstreamMuted') === 'true');
|
||||
console.log('Loaded Muted Status from Local Storage: ' + muted);
|
||||
}
|
||||
|
||||
// Asia Server Fallback
|
||||
if (localStorage.hstreamServerFallback) {
|
||||
serverFallback = (localStorage.getItem('hstreamServerFallback') == 'true');
|
||||
console.log('Loaded Server Fallback Status from Local Storage: ' + serverFallback);
|
||||
}
|
||||
|
||||
// Alert User when AV1 is not supported
|
||||
if (!av1Supported) {
|
||||
document.getElementById("av1-unsupported").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function initDash(data, player) {
|
||||
const video = document.querySelector('video');
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === player.config.quality.selected) {
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(video, el.src, true);
|
||||
// Expose player and dash so they can be used from the console
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setCanvasDimension(canvas, video) {
|
||||
canvas.height = video.offsetHeight;
|
||||
canvas.width = video.offsetWidth;
|
||||
}
|
||||
|
||||
function paintStaticVideo(ctx, video) {
|
||||
if (localStorage.theme == 'light') {
|
||||
return;
|
||||
}
|
||||
if (!ambientMode) {
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, video.offsetWidth, video.offsetHeight);
|
||||
}
|
||||
|
||||
function toggleAmbientMode() {
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
if (ambientMode) {
|
||||
ambientMode = false;
|
||||
localStorage.ambientMode = 'false';
|
||||
setCanvasDimension(canvas, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">Off</span></span>';
|
||||
} else {
|
||||
ambientMode = true;
|
||||
localStorage.ambientMode = 'true';
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
document.getElementById('ambient-mode-toggle').innerHTML = '<span>Ambient Mode<span class="plyr__menu__value">On</span></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAsiaServer() {
|
||||
if (serverFallback) {
|
||||
serverFallback = false;
|
||||
localStorage.hstreamServerFallback = 'false';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">Off</span></span>';
|
||||
streamServers = apiResponse.stream_domains;
|
||||
} else {
|
||||
serverFallback = true;
|
||||
localStorage.hstreamServerFallback = 'true';
|
||||
document.getElementById('server-fallback-toggle').innerHTML = '<span>Fallback Server<span class="plyr__menu__value">On</span></span>';
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
document.getElementById('av1-unsupported').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function initSubtitles(lang) {
|
||||
@@ -179,32 +71,28 @@ function initSubtitles(lang) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispose old instance
|
||||
if (subtitleInstance != null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
if (subtitleInstance !== null && subtitleInstance instanceof SubtitlesOctopus) {
|
||||
subtitleInstance.dispose();
|
||||
}
|
||||
|
||||
let newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
var newSubUrl = streamServer + '/' + apiResponse.stream_url + '/';
|
||||
|
||||
if (lang != 'en') {
|
||||
if (lang !== 'en') {
|
||||
newSubUrl += 'autotrans/' + lang + '.ass';
|
||||
}
|
||||
else {
|
||||
newSubUrl += 'eng.ass'
|
||||
} else {
|
||||
newSubUrl += 'eng.ass';
|
||||
}
|
||||
|
||||
let subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
// Hindi font
|
||||
if (lang == 'hi') {
|
||||
var subFont = '/fonts/Figtree-ExtraBold.woff2';
|
||||
if (lang === 'hi') {
|
||||
subFont = '/fonts/Hind-SemiBold.ttf';
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
var options = {
|
||||
video: document.getElementsByTagName('video')[0], // HTML5 video element
|
||||
subUrl: newSubUrl, // Link to subtitles
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js', // Link to WebAssembly-based file "libassjs-worker.js"
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js', // Link to non-WebAssembly worker
|
||||
video: document.getElementsByTagName('video')[0],
|
||||
subUrl: newSubUrl,
|
||||
workerUrl: '/build/js/subtitles-octopus-worker.js',
|
||||
legacyWorkerUrl: '/build/js/subtitles-octopus-worker-legacy.js',
|
||||
fonts: [subFont],
|
||||
renderMode: 'wasm-blend',
|
||||
};
|
||||
@@ -212,237 +100,154 @@ function initSubtitles(lang) {
|
||||
subtitleInstance = new SubtitlesOctopus(options);
|
||||
}
|
||||
|
||||
function initPlayerQualityChange(data) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', function () {
|
||||
initDash(data);
|
||||
});
|
||||
initDash(data);
|
||||
}
|
||||
}
|
||||
|
||||
function initDash(data) {
|
||||
var videoEl = document.querySelector('video');
|
||||
var quality = player.quality;
|
||||
|
||||
data.forEach(function (el) {
|
||||
if (el.mode === 'mpd' && el.size === quality) {
|
||||
var dash = dashjs.MediaPlayer().create();
|
||||
dash.initialize(videoEl, el.src, true);
|
||||
window.dash = dash;
|
||||
player.dash = dash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
player = new Plyr('#player', {
|
||||
controls,
|
||||
quality: {
|
||||
default: 720,
|
||||
options: [2161, 2160, 1081, 1080, 720]
|
||||
var videoEl = document.querySelector('#player');
|
||||
var container = videoEl.parentElement;
|
||||
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
var subtitleTracks = addSubtitleTracks(streamServer, apiResponse);
|
||||
var vttThumbsUrl = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
|
||||
player = new HStreamPlayer({
|
||||
container: container,
|
||||
video: videoEl,
|
||||
apiResponse: apiResponse,
|
||||
streamServer: streamServer,
|
||||
streamServers: streamServers,
|
||||
fallbackServers: fallbackServers,
|
||||
streamServerIndex: streamServerIndex,
|
||||
av1Supported: av1Supported,
|
||||
dashSupported: dashSupported,
|
||||
poster: apiResponse.poster,
|
||||
title: apiResponse.title,
|
||||
data: data,
|
||||
subtitleTracks: subtitleTracks,
|
||||
volume: volume,
|
||||
muted: muted,
|
||||
captionsActive: captions,
|
||||
captionLanguage: 'en',
|
||||
ambientMode: ambientMode,
|
||||
isMobile: isMobile(),
|
||||
quality: parseInt(localStorage.getItem('hstreamQuality')) || 1080,
|
||||
lastTime: lastTime,
|
||||
subtitleInstance: subtitleInstance,
|
||||
onEnded: function () {
|
||||
playNextPlaylistVideo();
|
||||
},
|
||||
i18n: {
|
||||
qualityLabel: {
|
||||
2161: "2160p48",
|
||||
2160: "2160p",
|
||||
1081: "1080p48",
|
||||
1080: "1080p",
|
||||
720: "720p"
|
||||
},
|
||||
qualityBadge: {
|
||||
2161: "UHD@48",
|
||||
1081: "FHD@48",
|
||||
1080: "FHD",
|
||||
},
|
||||
onTimeUpdate: function () {
|
||||
trackWatchTime();
|
||||
},
|
||||
onQualityChange: function (size) {
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
initDash(data);
|
||||
}
|
||||
},
|
||||
onVolumeChange: function () {
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString());
|
||||
},
|
||||
onCaptionsToggle: function (active) {
|
||||
localStorage.setItem('hstreamCaptions', active.toString());
|
||||
if (subtitleInstance && subtitleInstance.canvas) {
|
||||
subtitleInstance.canvas.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
var libassParent = document.querySelector('.libassjs-canvas-parent');
|
||||
if (libassParent) {
|
||||
libassParent.style.visibility = active ? 'visible' : 'hidden';
|
||||
}
|
||||
},
|
||||
onLanguageChange: function (lang) {
|
||||
initSubtitles(lang);
|
||||
if (player) {
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
},
|
||||
onServerChange: function (index) {
|
||||
streamServerIndex = index;
|
||||
var allServers = streamServers.concat(fallbackServers);
|
||||
streamServer = allServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
},
|
||||
fullscreen: { enabled: true, fallback: true, iosNative: true }
|
||||
});
|
||||
|
||||
// Player Track Data
|
||||
var data = addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported);
|
||||
window.player = player;
|
||||
|
||||
player.source = {
|
||||
type: 'video',
|
||||
title: apiResponse.title,
|
||||
poster: apiResponse.poster,
|
||||
previewThumbnails: {
|
||||
enabled: true,
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt',
|
||||
},
|
||||
sources: data,
|
||||
tracks: addSubtitleTracks(streamServer, apiResponse)
|
||||
};
|
||||
if (player.captionsActive) {
|
||||
initSubtitles(player.captionLanguage);
|
||||
player.setSubtitleInstance(subtitleInstance);
|
||||
}
|
||||
|
||||
player.volume = volume;
|
||||
player.muted = muted;
|
||||
//player.captions.languages = ['en'];
|
||||
player.captions.language = 'en';
|
||||
player.captions.active = captions;
|
||||
if (!isMobile()) {
|
||||
player.initThumbnails(vttThumbsUrl);
|
||||
}
|
||||
|
||||
if (dashSupported && !apiResponse.legacy) {
|
||||
player.on('qualitychange', () => {
|
||||
initDash(data, player);
|
||||
});
|
||||
|
||||
initDash(data, player);
|
||||
initDash(data);
|
||||
}
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
initMobileWidescreen(container, videoEl);
|
||||
initMobileDoubleTap(container, videoEl, player);
|
||||
|
||||
var allItems = document.getElementsByClassName('plyr__control--forward');
|
||||
var lastItem = allItems[allItems.length - 1];
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="ambient-mode-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Ambient Mode<span class="plyr__menu__value">On</span></span></button>');
|
||||
document.getElementById('ambient-mode-toggle').addEventListener('click', toggleAmbientMode);
|
||||
var episodeId = document.getElementById('e_id').value;
|
||||
player.initHeatmap(episodeId);
|
||||
|
||||
if (localStorage.ambientMode == 'false') {
|
||||
toggleAmbientMode();
|
||||
}
|
||||
|
||||
// Server select (Asia)
|
||||
lastItem = allItems[allItems.length - 1];
|
||||
let value = 'Off';
|
||||
if (serverFallback) { value = 'On'; }
|
||||
lastItem.insertAdjacentHTML('afterend', '<button id="server-fallback-toggle" type="button" class="plyr__control" role="menuitem" aria-haspopup="true"><span>Fallback Server<span class="plyr__menu__value">' + value + '</span></span></button>');
|
||||
document.getElementById('server-fallback-toggle').addEventListener('click', toggleAsiaServer);
|
||||
|
||||
var clickedPlay = false;
|
||||
|
||||
player.on('play', () => {
|
||||
if (!clickedPlay) {
|
||||
player.stop();
|
||||
console.log("Stopped video, because user didn't click play.")
|
||||
}
|
||||
|
||||
// Start engagement heatmap tracking
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
videoEl.addEventListener('play', function onFirstPlay() {
|
||||
videoEl.removeEventListener('play', onFirstPlay);
|
||||
startEngagementTracking(episodeId);
|
||||
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
player.on('seeked', () => {
|
||||
paintStaticVideo(ctx, video);
|
||||
if (player.currentTime > 0) {
|
||||
lastTime = player.currentTime;
|
||||
}
|
||||
console.log('Seeked => paintStaticVideo() at ' + player.currentTime);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
if (player.paused) {
|
||||
paintStaticVideo(ctx, video);
|
||||
}
|
||||
});
|
||||
|
||||
player.on('captionsenabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'visible';
|
||||
localStorage.setItem('hstreamCaptions', 'true');
|
||||
console.log('Set Captions Status to Local Storage: true');
|
||||
});
|
||||
|
||||
player.on('captionsdisabled', () => {
|
||||
document.getElementsByClassName('libassjs-canvas-parent')[0].style.visibility = 'hidden';
|
||||
localStorage.setItem('hstreamCaptions', 'false');
|
||||
console.log('Set Captions Status to Local Storage: false');
|
||||
});
|
||||
|
||||
player.on('volumechange', () => {
|
||||
console.log('Saving Audio Volume to Local Storage: ' + player.volume);
|
||||
localStorage.setItem('hstreamVolume', player.volume.toString())
|
||||
console.log('Saving Audio Muted to Local Storage: ' + player.muted.toString());
|
||||
localStorage.setItem('hstreamMuted', player.muted.toString())
|
||||
});
|
||||
|
||||
player.on('ended', () => {
|
||||
playNextPlaylistVideo();
|
||||
});
|
||||
|
||||
// Track watch time after 10 seconds of playback
|
||||
player.on('timeupdate', () => {
|
||||
trackWatchTime();
|
||||
});
|
||||
|
||||
player.on('languagechange', (event) => {
|
||||
let lang = event.detail.plyr.captions.language;
|
||||
|
||||
console.log('Subtitle Event ' + lang);
|
||||
initSubtitles(lang);
|
||||
});
|
||||
|
||||
function playerPlayTemp() {
|
||||
clickedPlay = true;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-plyr="play"]').forEach(play =>
|
||||
play.addEventListener('click', playerPlayTemp)
|
||||
);
|
||||
|
||||
document.getElementsByClassName('plyr--video')[0].addEventListener('click', playerPlayTemp);
|
||||
|
||||
initMobileWidescreen();
|
||||
|
||||
// Start time
|
||||
setTimeout(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const time = parseInt(params.get("t"));
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var time = parseInt(params.get('t'));
|
||||
if (!isNaN(time)) {
|
||||
player.currentTime = time;
|
||||
console.log("Skipping to " + time)
|
||||
console.log('Skipping to ' + time);
|
||||
}
|
||||
if (lastTime > 0) {
|
||||
player.currentTime = lastTime;
|
||||
console.log("Skipping to " + lastTime)
|
||||
console.log('Skipping to ' + lastTime);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
player.on('ready', () => {
|
||||
mobileDoubleClick(player);
|
||||
|
||||
// Load engagement heatmap once video duration is known
|
||||
const video = document.querySelector('video');
|
||||
const episodeId = document.getElementById('e_id').value;
|
||||
if (video && video.duration) {
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
} else if (video) {
|
||||
video.addEventListener('loadedmetadata', function onMeta() {
|
||||
video.removeEventListener('loadedmetadata', onMeta);
|
||||
renderHeatmap(episodeId, video.duration);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Server Select
|
||||
// I hate this...
|
||||
var settingElements = document.getElementsByClassName('plyr__control--forward');
|
||||
if (settingElements.length == 3) {
|
||||
settingElements[2].insertAdjacentHTML('afterend', serverSelectMenuItem(streamServerIndex));
|
||||
|
||||
var settingNodes = document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes;
|
||||
if (settingNodes.length == 4) {
|
||||
document.getElementsByClassName('plyr__menu__container')[0].childNodes[0].childNodes[3].insertAdjacentHTML('afterend', serverSelectSubmenu(streamServerIndex, streamServerCount));
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('server-select').addEventListener('click', serverSelectMenuClickToggle);
|
||||
document.getElementById('server-select-list-back-btn').addEventListener('click', serverSelectMenuClickToggle);
|
||||
let serverSelects = document.getElementsByClassName('change_server');
|
||||
for (let i = 0; i < serverSelects.length; i++) {
|
||||
serverSelects[i].addEventListener('click', function() {
|
||||
streamServerIndex = Number(this.value);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer);
|
||||
|
||||
if (player) {
|
||||
clearInterval(saveInterval);
|
||||
stopEngagementTracking();
|
||||
player.destroy();
|
||||
}
|
||||
initPlayer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically save last timestamp
|
||||
saveInterval = setInterval(function () {
|
||||
lastTime = player.currentTime;
|
||||
console.log("Last Player Position: " + lastTime);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
async function initVidstackPlayer() {
|
||||
const videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
const videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
const videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
var videoSource = streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4';
|
||||
var videoThumbs = streamServer + '/' + apiResponse.stream_url + '/thumbs.vtt';
|
||||
var videoCaption = streamServer + '/' + apiResponse.stream_url + '/eng.vtt';
|
||||
|
||||
player = await VidstackPlayer.create({
|
||||
target: '#player',
|
||||
@@ -464,57 +269,56 @@ async function initVidstackPlayer() {
|
||||
]
|
||||
});
|
||||
|
||||
// Ambient Mode
|
||||
let canvas = document.getElementById("ambientVideo"), ctx = canvas.getContext("2d"), video = document.getElementsByTagName('video')[0];
|
||||
setCanvasDimension(canvas, video);
|
||||
paintStaticVideo(ctx, video);
|
||||
window.player = player;
|
||||
|
||||
player.addEventListener('play', () => {
|
||||
setCanvasDimension(canvas, video);
|
||||
console.log('Play => Function Loop()');
|
||||
var $this = video;
|
||||
(function loop() {
|
||||
if (!player.paused && !player.ended && localStorage.theme == 'dark' && ambientMode) {
|
||||
ctx.drawImage($this, 0, 0, $this.offsetWidth, $this.offsetHeight);
|
||||
setTimeout(loop, 24000 / 1001); // drawing at 30fps
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Track watch time after 10 seconds of playback
|
||||
player.addEventListener('time-update', () => {
|
||||
player.addEventListener('time-update', function () {
|
||||
trackWatchTime();
|
||||
});
|
||||
}
|
||||
|
||||
// Get Data from API
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status == 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains;
|
||||
window.setPlayerPreference = function(pref) {
|
||||
localStorage.setItem('hstreamPlayerPreference', pref);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (serverFallback) {
|
||||
streamServers = apiResponse.asia_stream_domains;
|
||||
const playerPreference = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
|
||||
if (playerPreference === 'plyr' && !isIOS()) {
|
||||
import('./player-plyr.js').then(m => m.initPlyrPlayer(document.getElementById('e_id').value));
|
||||
} else {
|
||||
window.axios.post('/player/api', {
|
||||
episode_id: document.getElementById('e_id').value
|
||||
}).then(function (response) {
|
||||
if (response.status === 200) {
|
||||
apiResponse = response.data;
|
||||
streamServers = apiResponse.stream_domains || [];
|
||||
fallbackServers = apiResponse.asia_stream_domains || [];
|
||||
|
||||
const cdnCount = streamServers.length;
|
||||
if (cdnCount > 0) {
|
||||
streamServerIndex = Math.floor(Math.random() * cdnCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
} else {
|
||||
const fallbackCount = fallbackServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * fallbackCount);
|
||||
streamServer = fallbackServers[streamServerIndex];
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length + fallbackServers.length;
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
} else {
|
||||
console.log('Detected Apple device. Using Vidstack fallback player.');
|
||||
initVidstackPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
streamServerCount = streamServers.length;
|
||||
streamServerIndex = Math.floor(Math.random() * streamServerCount);
|
||||
streamServer = streamServers[streamServerIndex];
|
||||
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
|
||||
|
||||
if (!isIOS()) {
|
||||
initPlayer();
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById('player-alert');
|
||||
if (alert) {
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
console.log("Detected Apple Shit. Using different player.")
|
||||
initVidstackPlayer();
|
||||
}
|
||||
|
||||
}
|
||||
}).catch(function (error) {
|
||||
var alert = document.getElementById("player-alert");
|
||||
alert.innerText = 'The player encountered a problem: ' + error;
|
||||
alert.classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
export function addVideoTracks(streamServer, apiResponse, av1Supported, dashSupported) {
|
||||
if (dashSupported) {
|
||||
return addDashTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
return addLegacyTracks(streamServer, apiResponse, av1Supported);
|
||||
}
|
||||
|
||||
|
||||
function addDashTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/720/manifest.mpd',
|
||||
size: 720,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (av1Supported) {
|
||||
// 1080p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080/manifest.mpd',
|
||||
size: 1080,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
// 2160p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160/manifest.mpd',
|
||||
size: 2160,
|
||||
mode: 'mpd',
|
||||
});
|
||||
|
||||
if (apiResponse.interpolated == 1) {
|
||||
// 1080p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/1080i/manifest.mpd',
|
||||
size: 1081,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
|
||||
if (apiResponse.interpolated_uhd == 1) {
|
||||
// 2160p Interpolated
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/2160i/manifest.mpd',
|
||||
size: 2161,
|
||||
mode: 'mpd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function addLegacyTracks(streamServer, apiResponse, av1Supported) {
|
||||
var data = [];
|
||||
|
||||
// 720p
|
||||
data.push({
|
||||
src: streamServer + '/' + apiResponse.stream_url + '/x264.720p.mp4',
|
||||
type: 'video/mp4',
|
||||
size: 720,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
export function addSubtitleTracks(streamServer, apiResponse) {
|
||||
var data = [];
|
||||
|
||||
// Default
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: 'English',
|
||||
srclang: 'en',
|
||||
src: '',
|
||||
default: true,
|
||||
});
|
||||
|
||||
for (var key in apiResponse.extra_subtitles) {
|
||||
data.push({
|
||||
kind: 'captions',
|
||||
label: apiResponse.extra_subtitles[key] + ' (Auto Transl.)',
|
||||
srclang: key,
|
||||
src: '',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Engagement heatmap tracking
|
||||
// Samples the user's current time while playing and sends batched segment data to the server.
|
||||
// Only tracks segment >= 1 (excludes 0-10s).
|
||||
// Only calls the endpoint when the user is logged in.
|
||||
|
||||
let engagementInterval;
|
||||
let engagementSegments = new Set();
|
||||
let engagementReportInterval;
|
||||
const SEGMENT_DURATION = 10; // seconds per segment
|
||||
const SAMPLE_INTERVAL = 5000; // sample every 5s
|
||||
const REPORT_INTERVAL = 15000; // send batch every 15s
|
||||
|
||||
function isAuthenticated() {
|
||||
const el = document.getElementById('auth_check');
|
||||
return el && el.value === '1';
|
||||
}
|
||||
|
||||
function sendEngagement(episodeId, segments) {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
window.axios.post('/player/engagement', {
|
||||
episode_id: episodeId,
|
||||
segments: segments,
|
||||
}).catch(() => {
|
||||
// Fire-and-forget: silently ignore network errors
|
||||
});
|
||||
}
|
||||
|
||||
export function startEngagementTracking(episodeId) {
|
||||
engagementSegments.clear();
|
||||
|
||||
// Sample current time while playing
|
||||
engagementInterval = setInterval(() => {
|
||||
const video = document.querySelector('video');
|
||||
if (!video || video.paused) return;
|
||||
|
||||
const segment = Math.floor(video.currentTime / SEGMENT_DURATION);
|
||||
// Skip segment 0 (0-10s) — no need to track the very start
|
||||
if (segment >= 1) {
|
||||
engagementSegments.add(segment);
|
||||
}
|
||||
}, SAMPLE_INTERVAL);
|
||||
|
||||
// Batch report to server
|
||||
engagementReportInterval = setInterval(() => {
|
||||
if (engagementSegments.size === 0) return;
|
||||
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
|
||||
sendEngagement(episodeId, segments);
|
||||
}, REPORT_INTERVAL);
|
||||
|
||||
// Flush remaining segments & cleanup on page unload
|
||||
const cleanup = () => {
|
||||
clearInterval(engagementInterval);
|
||||
clearInterval(engagementReportInterval);
|
||||
|
||||
if (engagementSegments.size > 0) {
|
||||
const segments = Array.from(engagementSegments);
|
||||
engagementSegments.clear();
|
||||
sendEngagement(episodeId, segments);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', cleanup);
|
||||
}
|
||||
|
||||
export function stopEngagementTracking() {
|
||||
if (engagementInterval) clearInterval(engagementInterval);
|
||||
if (engagementReportInterval) clearInterval(engagementReportInterval);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Engagement heatmap display
|
||||
// Fetches aggregated watch data and renders vertical bar heatmap directly on the Plyr progress bar track.
|
||||
|
||||
let heatmapContainer = null;
|
||||
let heatmapCanvas = null;
|
||||
let heatmapResizeObserver = null;
|
||||
|
||||
/**
|
||||
* Fetch engagement data from the server and render the heatmap.
|
||||
* @param {string} episodeId - The episode ID.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
export async function renderHeatmap(episodeId, duration) {
|
||||
try {
|
||||
const response = await window.axios.get(`/player/engagement/${episodeId}`);
|
||||
const data = response.data;
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawHeatmapCurve(data, duration);
|
||||
} catch (error) {
|
||||
console.error('Failed to load engagement data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a smooth area chart on a canvas element above the progress bar.
|
||||
* @param {Object} data - Key-value map of segment -> watch_count.
|
||||
* @param {number} duration - Video duration in seconds.
|
||||
*/
|
||||
function drawHeatmapCurve(data, duration) {
|
||||
const SEGMENT_DURATION = 10;
|
||||
const totalSegments = Math.ceil(duration / SEGMENT_DURATION);
|
||||
|
||||
// Build raw counts array, filling gaps with 0
|
||||
const rawCounts = [];
|
||||
for (let i = 0; i < totalSegments; i++) {
|
||||
rawCounts.push(data[i] || 0);
|
||||
}
|
||||
|
||||
// Apply weighted moving average to smooth individual spikes
|
||||
const counts = smoothData(rawCounts);
|
||||
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
|
||||
// Remove existing heatmap if present
|
||||
if (heatmapContainer) {
|
||||
if (heatmapResizeObserver) heatmapResizeObserver.disconnect();
|
||||
heatmapContainer.remove();
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
|
||||
const progressBar = document.querySelector('.hstream-player__progress');
|
||||
if (!progressBar) return;
|
||||
|
||||
heatmapContainer = document.createElement('div');
|
||||
heatmapContainer.className = 'hstream-player__progress-heatmap';
|
||||
heatmapContainer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
heatmapCanvas = document.createElement('canvas');
|
||||
heatmapCanvas.className = 'hstream-player__progress-heatmap-canvas';
|
||||
heatmapContainer.appendChild(heatmapCanvas);
|
||||
|
||||
// Insert as first child of the progress bar so it sits behind the scrubber
|
||||
progressBar.insertBefore(heatmapContainer, progressBar.firstChild);
|
||||
|
||||
// Defer drawing to get container dimensions
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => drawCurve(heatmapCanvas, counts, maxCount));
|
||||
});
|
||||
|
||||
// Redraw on resize
|
||||
heatmapResizeObserver = new ResizeObserver(() => {
|
||||
drawCurve(heatmapCanvas, counts, maxCount);
|
||||
});
|
||||
heatmapResizeObserver.observe(heatmapContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply two passes of a 5-point weighted (Gaussian) moving average.
|
||||
* Near edges falls back to a 3-point average.
|
||||
* Preserves the first and last points.
|
||||
*/
|
||||
function smoothData(data) {
|
||||
if (data.length <= 2) return [...data];
|
||||
|
||||
let result = data;
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const smoothed = [result[0]];
|
||||
|
||||
for (let i = 1; i < result.length - 1; i++) {
|
||||
if (result.length > 4 && i >= 2 && i <= result.length - 3) {
|
||||
smoothed.push(
|
||||
(result[i - 2] * 1 + result[i - 1] * 2 + result[i] * 4 +
|
||||
result[i + 1] * 2 + result[i + 2] * 1) / 10
|
||||
);
|
||||
} else {
|
||||
smoothed.push((result[i - 1] + result[i] + result[i + 1]) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
smoothed.push(result[result.length - 1]);
|
||||
result = smoothed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vertical bar heatmap directly on the progress bar track.
|
||||
* Each bar represents a time segment; taller bars = more engagement.
|
||||
*/
|
||||
function drawCurve(canvas, counts, maxCount) {
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const rect = parent.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (counts.length === 0 || maxCount === 0) return;
|
||||
|
||||
const paddingX = 1;
|
||||
const paddingY = 2;
|
||||
const drawW = w - paddingX * 2;
|
||||
const drawH = h - paddingY * 2;
|
||||
const n = counts.length;
|
||||
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = paddingX + (i / (n - 1 || 1)) * drawW;
|
||||
const ratio = Math.min(counts[i] / maxCount, 1);
|
||||
const y = paddingY + (1 - ratio) * drawH;
|
||||
pts.push({ x, y });
|
||||
}
|
||||
|
||||
if (pts.length < 2) return;
|
||||
|
||||
// Build the smooth path using quadratic bezier curves through midpoints
|
||||
const path = [{ x: pts[0].x, y: pts[0].y }];
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const midX = (pts[i].x + pts[i + 1].x) / 2;
|
||||
const midY = (pts[i].y + pts[i + 1].y) / 2;
|
||||
path.push({ x: midX, y: midY, cp: { x: pts[i].x, y: pts[i].y } });
|
||||
}
|
||||
path.push({ x: pts[pts.length - 1].x, y: pts[pts.length - 1].y });
|
||||
|
||||
// --- Draw a subtle glow behind the line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.lineWidth = 3.0;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// --- Draw the main waveform line ---
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(path[0].x, path[0].y);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const prev = path[i - 1];
|
||||
const curr = path[i];
|
||||
if (curr.cp) {
|
||||
ctx.quadraticCurveTo(curr.cp.x, curr.cp.y, curr.x, curr.y);
|
||||
} else {
|
||||
ctx.lineTo(curr.x, curr.y);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the heatmap from the DOM.
|
||||
*/
|
||||
export function removeHeatmap() {
|
||||
if (heatmapResizeObserver) {
|
||||
heatmapResizeObserver.disconnect();
|
||||
heatmapResizeObserver = null;
|
||||
}
|
||||
if (heatmapContainer) {
|
||||
heatmapContainer.remove();
|
||||
heatmapContainer = null;
|
||||
heatmapCanvas = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Mobile-specific player features:
|
||||
* - Double-tap left/right to skip ±10s
|
||||
* - Object-fit toggle button for widescreen fill
|
||||
*/
|
||||
|
||||
export function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function initMobileWidescreen(playerWrapper, video) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = playerWrapper.querySelector('.hstream-player__controls');
|
||||
if (!controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'hstream-player__button hstream-player__mobile-fill-btn';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Toggle screen fill');
|
||||
btn.innerHTML = '<i class="fa-solid fa-arrows-left-right-to-line"></i>';
|
||||
btn.title = 'Fill Screen';
|
||||
|
||||
const fullscreenBtn = controls.querySelector('[data-action="fullscreen"]');
|
||||
if (fullscreenBtn) {
|
||||
fullscreenBtn.insertAdjacentElement('beforebegin', btn);
|
||||
} else {
|
||||
controls.appendChild(btn);
|
||||
}
|
||||
|
||||
let fillEnabled = true;
|
||||
video.style.objectFit = 'cover';
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (fillEnabled) {
|
||||
video.style.objectFit = 'contain';
|
||||
fillEnabled = false;
|
||||
btn.classList.remove('hstream-player__button--active');
|
||||
} else {
|
||||
video.style.objectFit = 'cover';
|
||||
fillEnabled = true;
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
});
|
||||
|
||||
btn.classList.add('hstream-player__button--active');
|
||||
}
|
||||
|
||||
export function initMobileDoubleTap(playerWrapper, video, player) {
|
||||
if (!isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipOverlay = playerWrapper.querySelector('.hstream-player__skip-overlay');
|
||||
if (!skipOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
class MultiClickCounter {
|
||||
constructor() {
|
||||
this.timers = [];
|
||||
this.count = 0;
|
||||
this.reseted = 0;
|
||||
this.lastSide = null;
|
||||
}
|
||||
|
||||
clicked() {
|
||||
this.count += 1;
|
||||
const xcount = this.count;
|
||||
this.timers.push(setTimeout(() => this.reset(xcount), 500));
|
||||
return this.count;
|
||||
}
|
||||
|
||||
resetCount(n) {
|
||||
this.reseted = this.count;
|
||||
this.count = n;
|
||||
this.timers.forEach(t => clearTimeout(t));
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
reset(xcount) {
|
||||
if (this.count > xcount) return;
|
||||
this.count = 0;
|
||||
this.lastSide = null;
|
||||
this.reseted = 0;
|
||||
skipOverlay.classList.remove('hstream-player__skip-overlay--visible');
|
||||
this.timers = [];
|
||||
}
|
||||
}
|
||||
|
||||
const counter = new MultiClickCounter();
|
||||
|
||||
const handleTap = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const count = counter.clicked();
|
||||
if (count < 2) return;
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const perc = (x / rect.width) * 100;
|
||||
|
||||
let shouldReset = true;
|
||||
const lastSide = counter.lastSide;
|
||||
|
||||
if (lastSide === null) {
|
||||
shouldReset = false;
|
||||
}
|
||||
|
||||
if (perc < 40) {
|
||||
if (player.currentTime === 0) return;
|
||||
counter.lastSide = 'L';
|
||||
if (shouldReset && lastSide !== 'L') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.max(0, player.currentTime - skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-backward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else if (perc > 60) {
|
||||
if (player.currentTime >= player.duration) return;
|
||||
counter.lastSide = 'R';
|
||||
if (shouldReset && lastSide !== 'R') {
|
||||
counter.resetCount(1);
|
||||
return;
|
||||
}
|
||||
const skipSeconds = (count - 1) * 10;
|
||||
player.currentTime = Math.min(player.duration, player.currentTime + skipSeconds);
|
||||
skipOverlay.innerHTML = '<i class="fa-solid fa-forward"></i>' + skipSeconds + 's';
|
||||
skipOverlay.classList.add('hstream-player__skip-overlay--visible');
|
||||
setTimeout(() => skipOverlay.classList.remove('hstream-player__skip-overlay--visible'), 800);
|
||||
} else {
|
||||
player.togglePlay();
|
||||
counter.lastSide = 'C';
|
||||
}
|
||||
};
|
||||
|
||||
playerWrapper.addEventListener('click', handleTap);
|
||||
|
||||
video.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Builds the server/CDN selector submenu panel for the settings menu.
|
||||
* @param {string[]} streamServers - Regular CDN server URLs
|
||||
* @param {string[]} fallbackServers - Fallback server URLs
|
||||
* @param {number} selectedIndex - Index in the combined server list
|
||||
* @param {function} onSelect - Callback receiving the combined index
|
||||
*/
|
||||
export function buildServerMenu(streamServers, fallbackServers, selectedIndex, onSelect) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hstream-player__menu-panel';
|
||||
panel.setAttribute('data-panel', 'server');
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'hstream-player__menu-back';
|
||||
backBtn.type = 'button';
|
||||
backBtn.innerHTML = '<i class="fa-solid fa-chevron-left"></i> Server';
|
||||
backBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const menuContainer = panel.closest('.hstream-player__menu-container');
|
||||
if (menuContainer) {
|
||||
menuContainer.querySelectorAll('.hstream-player__menu-panel').forEach(p => p.classList.remove('hstream-player__menu-panel--active'));
|
||||
const mainPanel = menuContainer.querySelector('[data-panel="main"]');
|
||||
if (mainPanel) mainPanel.classList.add('hstream-player__menu-panel--active');
|
||||
}
|
||||
});
|
||||
panel.appendChild(backBtn);
|
||||
|
||||
const addServerItems = (servers, labelPrefix, startIndex) => {
|
||||
for (let i = 0; i < servers.length; i++) {
|
||||
const index = startIndex + i;
|
||||
const item = document.createElement('button');
|
||||
item.className = 'hstream-player__menu-item';
|
||||
item.type = 'button';
|
||||
item.setAttribute('role', 'menuitemradio');
|
||||
|
||||
if (index === selectedIndex) {
|
||||
item.classList.add('hstream-player__menu-item--checked');
|
||||
item.setAttribute('aria-checked', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
}
|
||||
|
||||
const num = i + 1;
|
||||
item.innerHTML = `<span>${labelPrefix} ${num} <span class="hstream-player__menu-value"><span class="hstream-player__menu-badge">${labelPrefix}${num}</span></span></span><span class="hstream-player__menu-item-radio"></span>`;
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(index);
|
||||
});
|
||||
panel.appendChild(item);
|
||||
}
|
||||
};
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(divider);
|
||||
|
||||
addServerItems(streamServers, 'Server', 0);
|
||||
|
||||
if (fallbackServers && fallbackServers.length > 0) {
|
||||
const fbDivider = document.createElement('div');
|
||||
fbDivider.className = 'hstream-player__menu-divider';
|
||||
panel.appendChild(fbDivider);
|
||||
|
||||
addServerItems(fallbackServers, 'Fallback', streamServers.length);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* VTT-based sprite thumbnail preview.
|
||||
* Parses WEBVTT cues with Media Fragment URIs (#xywh=x,y,w,h) and renders
|
||||
* a floating preview image above the progress bar on hover.
|
||||
*/
|
||||
|
||||
export class ThumbnailPreview {
|
||||
constructor(progressWrapper, video) {
|
||||
this.progressWrapper = progressWrapper;
|
||||
this.video = video;
|
||||
this.cues = [];
|
||||
this.spriteImg = null;
|
||||
this.thumbnailWidth = 160;
|
||||
this.thumbnailHeight = 90;
|
||||
this.visible = false;
|
||||
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'hstream-player__thumbnail-preview';
|
||||
this.el.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.imgEl = document.createElement('div');
|
||||
this.imgEl.className = 'hstream-player__thumbnail-preview-img';
|
||||
this.el.appendChild(this.imgEl);
|
||||
|
||||
this.timeEl = document.createElement('div');
|
||||
this.timeEl.className = 'hstream-player__thumbnail-preview-time';
|
||||
this.el.appendChild(this.timeEl);
|
||||
|
||||
this.el.style.display = 'none';
|
||||
this.progressWrapper.appendChild(this.el);
|
||||
|
||||
this._onMove = this._onMove.bind(this);
|
||||
this._onLeave = this._onLeave.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse the thumbnail VTT file.
|
||||
* @param {string} vttUrl
|
||||
*/
|
||||
async load(vttUrl) {
|
||||
try {
|
||||
const response = await fetch(vttUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VTT: ' + response.status);
|
||||
}
|
||||
const text = await response.text();
|
||||
const baseDir = vttUrl.substring(0, vttUrl.lastIndexOf('/') + 1);
|
||||
this.cues = this._parseVTT(text, baseDir);
|
||||
if (this.cues.length > 0) {
|
||||
this.spriteImg = new Image();
|
||||
this.spriteImg.crossOrigin = 'anonymous';
|
||||
this.spriteImg.src = this.cues[0].spriteUrl;
|
||||
await new Promise((resolve, reject) => {
|
||||
this.spriteImg.onload = resolve;
|
||||
this.spriteImg.onerror = reject;
|
||||
});
|
||||
}
|
||||
this._attach();
|
||||
} catch (err) {
|
||||
console.warn('[ThumbnailPreview] Could not load thumbnails:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WEBVTT text extracting cues with sprite coordinates.
|
||||
*/
|
||||
_parseVTT(text, baseDir) {
|
||||
const cues = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
const cueRegex = /^(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/;
|
||||
const xywhRegex = /#xywh=(\d+),(\d+),(\d+),(\d+)/;
|
||||
|
||||
const resolveUrl = (maybeRelative) => {
|
||||
if (!baseDir || maybeRelative.startsWith('http://') || maybeRelative.startsWith('https://') || maybeRelative.startsWith('data:') || maybeRelative.startsWith('/')) {
|
||||
return maybeRelative;
|
||||
}
|
||||
try {
|
||||
return new URL(maybeRelative, baseDir).href;
|
||||
} catch (e) {
|
||||
return baseDir + maybeRelative;
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
const match = line.match(cueRegex);
|
||||
if (match) {
|
||||
const startTime = this._timeToSeconds(match[1]);
|
||||
const endTime = this._timeToSeconds(match[2]);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const payload = lines[i].trim();
|
||||
if (payload === '' || payload.match(cueRegex)) {
|
||||
break;
|
||||
}
|
||||
const xywh = payload.match(xywhRegex);
|
||||
if (xywh) {
|
||||
const rawUrl = payload.substring(0, xywh.index);
|
||||
cues.push({
|
||||
startTime,
|
||||
endTime,
|
||||
spriteUrl: resolveUrl(rawUrl),
|
||||
x: parseInt(xywh[1], 10),
|
||||
y: parseInt(xywh[2], 10),
|
||||
w: parseInt(xywh[3], 10),
|
||||
h: parseInt(xywh[4], 10),
|
||||
});
|
||||
break;
|
||||
}
|
||||
const noteMatch = payload.match(/^NOTE/);
|
||||
if (!noteMatch) {
|
||||
const urlMatch = payload.match(/^(\S+)/);
|
||||
if (urlMatch) {
|
||||
cues.push({ startTime, endTime, spriteUrl: resolveUrl(urlMatch[1]), x: 0, y: 0, w: 0, h: 0 });
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
_timeToSeconds(timestamp) {
|
||||
const [h, m, s] = timestamp.split(':');
|
||||
return parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s);
|
||||
}
|
||||
|
||||
_attach() {
|
||||
this.progressWrapper.addEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.addEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.addEventListener('touchmove', this._onMove, { passive: true });
|
||||
this.progressWrapper.addEventListener('touchend', this._onLeave);
|
||||
}
|
||||
|
||||
_onMove(e) {
|
||||
const rect = this.progressWrapper.getBoundingClientRect();
|
||||
const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const time = ratio * this.video.duration;
|
||||
|
||||
const cue = this._findCue(time);
|
||||
if (!cue) {
|
||||
this._hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this._show(cue, time, rect, x);
|
||||
}
|
||||
|
||||
_findCue(time) {
|
||||
for (let i = 0; i < this.cues.length; i++) {
|
||||
if (time >= this.cues[i].startTime && time <= this.cues[i].endTime) {
|
||||
return this.cues[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_show(cue, time, progressRect, mouseX) {
|
||||
this.imgEl.style.backgroundImage = `url(${cue.spriteUrl})`;
|
||||
this.imgEl.style.width = cue.w + 'px';
|
||||
this.imgEl.style.height = cue.h + 'px';
|
||||
this.imgEl.style.backgroundPosition = `-${cue.x}px -${cue.y}px`;
|
||||
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
this.timeEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
|
||||
const containerWidth = this.progressWrapper.offsetWidth;
|
||||
const halfW = cue.w / 2;
|
||||
let left = mouseX;
|
||||
if (left < halfW + 4) left = halfW + 4;
|
||||
if (left > containerWidth - halfW - 4) left = containerWidth - halfW - 4;
|
||||
|
||||
this.el.style.left = left + 'px';
|
||||
this.el.style.display = '';
|
||||
|
||||
const timeTooltip = this.progressWrapper.querySelector('.hstream-player__time-tooltip');
|
||||
if (timeTooltip) {
|
||||
timeTooltip.classList.remove('hstream-player__time-tooltip--visible');
|
||||
}
|
||||
|
||||
if (!this.visible) {
|
||||
this.visible = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.el.classList.add('hstream-player__thumbnail-preview--visible');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_hide() {
|
||||
this.visible = false;
|
||||
this.el.classList.remove('hstream-player__thumbnail-preview--visible');
|
||||
setTimeout(() => {
|
||||
if (!this.visible) {
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
_onLeave() {
|
||||
this._hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.progressWrapper.removeEventListener('mousemove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('mouseleave', this._onLeave);
|
||||
this.progressWrapper.removeEventListener('touchmove', this._onMove);
|
||||
this.progressWrapper.removeEventListener('touchend', this._onLeave);
|
||||
if (this.el.parentNode) {
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,42 @@
|
||||
@endif
|
||||
<canvas id="ambientVideo" class="decoy"></canvas>
|
||||
<div class="relative w-full aspect-[16/9]">
|
||||
<video id="player" playsinline controls crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
<div class="player-switcher" id="player-switcher">
|
||||
<span class="player-switcher__label">Player</span>
|
||||
<button type="button" class="player-switcher__btn" id="plyr-toggle-btn"
|
||||
onclick="window.setPlayerPreference('plyr')" title="Switch to Plyr player">
|
||||
<span class="player-switcher__dot"></span> Plyr
|
||||
</button>
|
||||
<button type="button" class="player-switcher__btn player-switcher__btn--active" id="hstream-toggle-btn"
|
||||
onclick="window.setPlayerPreference('hstream')" title="Switch to HStream player">
|
||||
<span class="player-switcher__dot"></span> HStream
|
||||
</button>
|
||||
</div>
|
||||
<video id="player" playsinline crossorigin class="absolute inset-0 w-full h-full"></video>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var pref = localStorage.getItem('hstreamPlayerPreference') || 'hstream';
|
||||
var hstreamBtn = document.getElementById('hstream-toggle-btn');
|
||||
var plyrBtn = document.getElementById('plyr-toggle-btn');
|
||||
if (pref === 'plyr') {
|
||||
hstreamBtn.classList.remove('player-switcher__btn--active');
|
||||
plyrBtn.classList.add('player-switcher__btn--active');
|
||||
}
|
||||
|
||||
var switcher = document.getElementById('player-switcher');
|
||||
var container = switcher.parentElement;
|
||||
var hideTimer = null;
|
||||
|
||||
function showSwitcher() {
|
||||
switcher.classList.add('player-switcher--visible');
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(function () {
|
||||
switcher.classList.remove('player-switcher--visible');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
container.addEventListener('pointerdown', showSwitcher);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
+1
-2
@@ -12,9 +12,8 @@ export default defineConfig({
|
||||
'resources/js/app.js',
|
||||
'resources/js/modals-playlist.js',
|
||||
'resources/js/theme.js',
|
||||
'resources/js/player-mobile.js',
|
||||
'resources/js/player-data.js',
|
||||
'resources/js/player.js',
|
||||
'resources/js/player-plyr.js',
|
||||
'resources/js/playlist.js',
|
||||
'resources/js/upload.js',
|
||||
'resources/js/user-blacklist.js',
|
||||
|
||||
Reference in New Issue
Block a user