Custom video player

This commit is contained in:
2026-07-26 22:26:37 +02:00
parent 7553b9f895
commit 2e3918def4
13 changed files with 3547 additions and 905 deletions
+159 -374
View File
@@ -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,146 @@ 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);
},
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)
};
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 +261,45 @@ 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) {
if (response.status === 200) {
apiResponse = response.data;
streamServers = apiResponse.stream_domains;
streamServers = apiResponse.stream_domains || [];
fallbackServers = apiResponse.asia_stream_domains || [];
if (serverFallback) {
streamServers = 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;
streamServerIndex = Math.floor(Math.random() * streamServerCount);
streamServer = streamServers[streamServerIndex];
streamServerCount = streamServers.length + fallbackServers.length;
console.log('Selected Server: ' + streamServer + ' with Index: ' + streamServerIndex);
if (!isIOS()) {
initPlayer();
}
else {
console.log("Detected Apple Shit. Using different player.")
} else {
console.log('Detected Apple device. Using Vidstack fallback player.');
initVidstackPlayer();
}
}
}).catch(function (error) {
var alert = document.getElementById("player-alert");
alert.innerText = 'The player encountered a problem: ' + error;
alert.classList.remove("hidden");
var alert = document.getElementById('player-alert');
if (alert) {
alert.innerText = 'The player encountered a problem: ' + error;
alert.classList.remove('hidden');
}
});