Add HLS support to multi-audio

This commit is contained in:
2026-04-05 14:56:51 -05:00
parent 62a028968e
commit f0649be5de
19 changed files with 2256 additions and 164 deletions

2
youtube/static/js/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,10 +13,12 @@
let qualityOptions = [];
let qualityDefault;
// Collect uni sources (integrated)
for (let src of data.uni_sources) {
qualityOptions.push(src.quality_string);
}
// Collect pair sources (av-merge)
for (let src of data.pair_sources) {
qualityOptions.push(src.quality_string);
}
@@ -29,6 +31,37 @@
qualityDefault = 'None';
}
// Current av-merge instance
let avMerge = null;
// Change quality: handles both uni (integrated) and pair (av-merge)
function changeQuality(selection) {
let currentVideoTime = video.currentTime;
let videoPaused = video.paused;
let videoSpeed = video.playbackRate;
let srcInfo;
// Close previous av-merge if any
if (avMerge && typeof avMerge.close === 'function') {
avMerge.close();
}
if (selection.type == 'uni') {
srcInfo = data.uni_sources[selection.index];
video.src = srcInfo.url;
avMerge = null;
} else {
srcInfo = data.pair_sources[selection.index];
avMerge = new AVMerge(video, srcInfo, currentVideoTime);
}
video.currentTime = currentVideoTime;
if (!videoPaused) {
video.play();
}
video.playbackRate = videoSpeed;
}
// Fix plyr refusing to work with qualities that are strings
Object.defineProperty(Plyr.prototype, 'quality', {
set: function (input) {
@@ -59,7 +92,6 @@
});
const playerOptions = {
// Learning about autoplay permission https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/autoplay#syntax
autoplay: autoplayActive,
disableContextMenu: false,
captions: {
@@ -92,6 +124,7 @@
if (quality == 'None') {
return;
}
// Check if it's a uni source (integrated)
if (quality.includes('(integrated)')) {
for (let i = 0; i < data.uni_sources.length; i++) {
if (data.uni_sources[i].quality_string == quality) {
@@ -100,6 +133,7 @@
}
}
} else {
// It's a pair source (av-merge)
for (let i = 0; i < data.pair_sources.length; i++) {
if (data.pair_sources[i].quality_string == quality) {
changeQuality({ type: 'pair', index: i });
@@ -117,20 +151,30 @@
tooltips: {
controls: true,
},
}
};
const player = new Plyr(document.getElementById('js-video-player'), playerOptions);
const video = document.getElementById('js-video-player');
const player = new Plyr(video, playerOptions);
// Hide audio track selector (DASH doesn't support multi-audio)
const audioContainer = document.getElementById('plyr-audio-container');
if (audioContainer) audioContainer.style.display = 'none';
// disable double click to fullscreen
// https://github.com/sampotts/plyr/issues/1370#issuecomment-528966795
player.eventListeners.forEach(function(eventListener) {
if(eventListener.type === 'dblclick') {
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
}
});
// Add .started property, true after the playback has been started
// Needed so controls won't be hidden before playback has started
// Add .started property
player.started = false;
player.once('playing', function(){this.started = true});
player.once('playing', function(){ this.started = true; });
// Set initial time
if (data.time_start != 0) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
})();

View File

@@ -0,0 +1,536 @@
(function main() {
'use strict';
console.log('Plyr start script loaded');
// Captions
let captionsActive = false;
if (typeof data !== 'undefined' && (data.settings.subtitles_mode === 2 || (data.settings.subtitles_mode === 1 && data.has_manual_captions))) {
captionsActive = true;
}
// AutoPlay
let autoplayActive = typeof data !== 'undefined' && data.settings.autoplay_videos || false;
// Quality map: label -> hls level index
window.hlsQualityMap = {};
let plyrInstance = null;
let currentQuality = 'auto';
let hls = null;
window.hls = null;
/**
* Get start level from settings (highest quality <= target)
*/
function getStartLevel(levels) {
if (typeof data === 'undefined' || !data.settings) return -1;
const defaultRes = data.settings.default_resolution;
if (defaultRes === 'auto' || !defaultRes) return -1;
const target = parseInt(defaultRes);
// Find the level with the highest height that is still <= target
let bestLevel = -1;
let bestHeight = 0;
for (let i = 0; i < levels.length; i++) {
const h = levels[i].height;
if (h <= target && h > bestHeight) {
bestHeight = h;
bestLevel = i;
}
}
return bestLevel;
}
/**
* Initialize HLS
*/
function initHLS(manifestUrl) {
return new Promise((resolve, reject) => {
if (!manifestUrl) {
reject('No HLS manifest URL provided');
return;
}
console.log('Initializing HLS for Plyr:', manifestUrl);
if (hls) {
hls.destroy();
hls = null;
}
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
window.hls = hls;
const video = document.getElementById('js-video-player');
if (!video) {
reject('Video element not found');
return;
}
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
console.log('HLS manifest parsed, levels:', hls.levels?.length);
// Set initial quality from settings
const startLevel = getStartLevel(hls.levels);
if (startLevel !== -1) {
hls.currentLevel = startLevel;
const level = hls.levels[startLevel];
currentQuality = level.height + 'p';
console.log('Starting at resolution:', currentQuality);
}
resolve(hls);
});
hls.on(Hls.Events.ERROR, function(_, data) {
if (data.fatal) {
console.error('HLS fatal error:', data.type, data.details);
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
reject(data);
break;
}
}
});
});
}
/**
* Change HLS quality
*/
function changeHLSQuality(quality) {
if (!hls) {
console.error('HLS not available');
return;
}
console.log('Changing HLS quality to:', quality);
if (quality === 'auto') {
hls.currentLevel = -1;
currentQuality = 'auto';
console.log('HLS quality set to Auto');
const qualityBtnText = document.getElementById('plyr-quality-text');
if (qualityBtnText) {
qualityBtnText.textContent = 'Auto';
}
} else {
const levelIndex = window.hlsQualityMap[quality];
if (levelIndex !== undefined) {
hls.currentLevel = levelIndex;
currentQuality = quality;
console.log('HLS quality set to:', quality);
const qualityBtnText = document.getElementById('plyr-quality-text');
if (qualityBtnText) {
qualityBtnText.textContent = quality;
}
}
}
}
/**
* Create custom quality control in Plyr controls
*/
function addCustomQualityControl(player, qualityLabels) {
player.on('ready', () => {
console.log('Adding custom quality control...');
const controls = player.elements.container.querySelector('.plyr__controls');
if (!controls) {
console.error('Controls not found');
return;
}
if (document.getElementById('plyr-quality-container')) {
console.log('Quality control already exists');
return;
}
const qualityContainer = document.createElement('div');
qualityContainer.id = 'plyr-quality-container';
qualityContainer.className = 'plyr__control plyr__control--custom';
const qualityButton = document.createElement('button');
qualityButton.type = 'button';
qualityButton.className = 'plyr__control';
qualityButton.setAttribute('data-plyr', 'quality-custom');
qualityButton.setAttribute('aria-label', 'Quality');
qualityButton.innerHTML = `
<svg class="plyr__icon hls_quality_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect>
<line x1="8" y1="12" x2="16" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="16"></line>
</svg>
<span id="plyr-quality-text">${currentQuality === 'auto' ? 'Auto' : currentQuality}</span>
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
const dropdown = document.createElement('div');
dropdown.className = 'plyr-quality-dropdown';
qualityLabels.forEach(label => {
const option = document.createElement('div');
option.className = 'plyr-quality-option';
option.textContent = label === 'auto' ? 'Auto' : label;
if (label === currentQuality) {
option.setAttribute('data-active', 'true');
}
option.addEventListener('click', (e) => {
e.stopPropagation();
changeHLSQuality(label);
dropdown.querySelectorAll('.plyr-quality-option').forEach(opt => {
opt.removeAttribute('data-active');
});
option.setAttribute('data-active', 'true');
dropdown.style.display = 'none';
});
dropdown.appendChild(option);
});
qualityButton.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = dropdown.style.display === 'block';
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
d.style.display = 'none';
});
dropdown.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!qualityContainer.contains(e.target)) {
dropdown.style.display = 'none';
}
});
qualityContainer.appendChild(qualityButton);
qualityContainer.appendChild(dropdown);
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
if (settingsBtn) {
settingsBtn.insertAdjacentElement('beforebegin', qualityContainer);
} else {
controls.appendChild(qualityContainer);
}
console.log('Custom quality control added');
});
}
/**
* Create custom audio tracks control in Plyr controls
*/
function addCustomAudioTracksControl(player, hlsInstance) {
player.on('ready', () => {
console.log('Adding custom audio tracks control...');
const controls = player.elements.container.querySelector('.plyr__controls');
if (!controls) {
console.error('Controls not found');
return;
}
if (document.getElementById('plyr-audio-container')) {
console.log('Audio tracks control already exists');
return;
}
const audioContainer = document.createElement('div');
audioContainer.id = 'plyr-audio-container';
audioContainer.className = 'plyr__control plyr__control--custom';
const audioButton = document.createElement('button');
audioButton.type = 'button';
audioButton.className = 'plyr__control';
audioButton.setAttribute('data-plyr', 'audio-custom');
audioButton.setAttribute('aria-label', 'Audio Track');
audioButton.innerHTML = `
<svg class="plyr__icon hls_audio_icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3z"></path>
<path d="M3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>
</svg>
<span id="plyr-audio-text">Audio</span>
<svg class="plyr__icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`;
const audioDropdown = document.createElement('div');
audioDropdown.className = 'plyr-audio-dropdown';
function updateAudioDropdown() {
if (!hlsInstance || !hlsInstance.audioTracks) return;
audioDropdown.innerHTML = '';
if (hlsInstance.audioTracks.length === 0) {
const noTrackMsg = document.createElement('div');
noTrackMsg.className = 'plyr-audio-no-tracks';
noTrackMsg.textContent = 'No audio tracks';
audioDropdown.appendChild(noTrackMsg);
return;
}
hlsInstance.audioTracks.forEach((track, idx) => {
const option = document.createElement('div');
option.className = 'plyr-audio-option';
option.textContent = track.name || track.lang || `Track ${idx + 1}`;
if (hlsInstance.audioTrack === idx) {
option.setAttribute('data-active', 'true');
}
option.addEventListener('click', (e) => {
e.stopPropagation();
hlsInstance.audioTrack = idx;
console.log('Audio track changed to:', track.name || track.lang || idx);
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = track.name || track.lang || `Track ${idx + 1}`;
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
audioDropdown.querySelectorAll('.plyr-audio-option').forEach(opt => {
opt.removeAttribute('data-active');
});
option.setAttribute('data-active', 'true');
audioDropdown.style.display = 'none';
});
audioDropdown.appendChild(option);
});
}
audioButton.addEventListener('click', (e) => {
e.stopPropagation();
updateAudioDropdown();
const isVisible = audioDropdown.style.display === 'block';
document.querySelectorAll('.plyr-quality-dropdown, .plyr-audio-dropdown').forEach(d => {
d.style.display = 'none';
});
audioDropdown.style.display = isVisible ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!audioContainer.contains(e.target)) {
audioDropdown.style.display = 'none';
}
});
audioContainer.appendChild(audioButton);
audioContainer.appendChild(audioDropdown);
const qualityContainer = document.getElementById('plyr-quality-container');
if (qualityContainer) {
qualityContainer.insertAdjacentElement('beforebegin', audioContainer);
} else {
const settingsBtn = controls.querySelector('[data-plyr="settings"]');
if (settingsBtn) {
settingsBtn.insertAdjacentElement('beforebegin', audioContainer);
} else {
controls.appendChild(audioContainer);
}
}
if (hlsInstance && hlsInstance.audioTracks && hlsInstance.audioTracks.length > 0) {
// Prefer "original" audio track
const originalIdx = hlsInstance.audioTracks.findIndex(t =>
(t.name || '').toLowerCase().includes('original')
);
if (originalIdx !== -1) {
hlsInstance.audioTrack = originalIdx;
console.log('Selected original audio track:', hlsInstance.audioTracks[originalIdx].name);
}
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
if (currentTrack) {
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
}
}
hlsInstance.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
console.log('Audio tracks updated, count:', hlsInstance.audioTracks?.length);
if (hlsInstance.audioTracks?.length > 0) {
updateAudioDropdown();
const currentTrack = hlsInstance.audioTracks[hlsInstance.audioTrack];
if (currentTrack) {
const audioText = document.getElementById('plyr-audio-text');
if (audioText) {
const trackName = currentTrack.name || currentTrack.lang || 'Audio';
audioText.textContent = trackName.length > 8 ? trackName.substring(0, 6) + '...' : trackName;
}
}
}
});
console.log('Custom audio tracks control added');
});
}
/**
* Initialize Plyr with HLS quality options
*/
function initPlyrWithQuality(hlsInstance) {
const video = document.getElementById('js-video-player');
if (!hlsInstance || !hlsInstance.levels || hlsInstance.levels.length === 0) {
console.error('HLS not ready');
return;
}
if (!video) {
console.error('Video element not found');
return;
}
console.log('HLS levels available:', hlsInstance.levels.length);
const sortedLevels = [...hlsInstance.levels].sort((a, b) => b.height - a.height);
const seenHeights = new Set();
const uniqueLevels = [];
sortedLevels.forEach((level) => {
if (!seenHeights.has(level.height)) {
seenHeights.add(level.height);
uniqueLevels.push(level);
}
});
const qualityLabels = ['auto'];
uniqueLevels.forEach((level) => {
const originalIndex = hlsInstance.levels.indexOf(level);
const label = level.height + 'p';
if (!window.hlsQualityMap[label]) {
qualityLabels.push(label);
window.hlsQualityMap[label] = originalIndex;
}
});
console.log('Quality labels:', qualityLabels);
const playerOptions = {
autoplay: autoplayActive,
disableContextMenu: false,
captions: {
active: captionsActive,
language: typeof data !== 'undefined' ? data.settings.subtitles_language : 'en',
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen',
],
iconUrl: '/youtube.com/static/modules/plyr/plyr.svg',
blankVideo: '/youtube.com/static/modules/plyr/blank.webm',
debug: false,
storage: { enabled: false },
previewThumbnails: {
enabled: typeof storyboard_url !== 'undefined' && storyboard_url !== null,
src: typeof storyboard_url !== 'undefined' && storyboard_url !== null ? [storyboard_url] : [],
},
settings: ['captions', 'speed', 'loop'],
tooltips: {
controls: true,
},
};
console.log('Creating Plyr...');
try {
plyrInstance = new Plyr(video, playerOptions);
console.log('Plyr instance created');
window.plyrInstance = plyrInstance;
addCustomQualityControl(plyrInstance, qualityLabels);
addCustomAudioTracksControl(plyrInstance, hlsInstance);
if (plyrInstance.eventListeners) {
plyrInstance.eventListeners.forEach(function(eventListener) {
if(eventListener.type === 'dblclick') {
eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
}
});
}
plyrInstance.started = false;
plyrInstance.once('playing', function(){this.started = true});
if (typeof data !== 'undefined' && data.time_start != 0) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
console.log('Plyr init complete');
} catch (e) {
console.error('Failed to initialize Plyr:', e);
}
}
/**
* Main initialization
*/
async function start() {
console.log('Starting Plyr with HLS...');
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
console.error('No HLS manifest URL available');
return;
}
try {
const hlsInstance = await initHLS(hls_manifest_url);
initPlyrWithQuality(hlsInstance);
} catch (error) {
console.error('Failed to initialize:', error);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();

View File

@@ -0,0 +1,375 @@
/**
* YouTube Storyboard Preview Thumbnails
* Shows preview thumbnails when hovering over the progress bar
* Works with native HTML5 video player
*
* Fetches the proxied WebVTT storyboard from backend and extracts image URLs
*/
(function() {
'use strict';
console.log('Storyboard Preview Thumbnails loaded');
// Storyboard configuration
let storyboardImages = []; // Array of {time, imageUrl, x, y, width, height}
let previewElement = null;
let tooltipElement = null;
let video = null;
let progressBarRect = null;
/**
* Fetch and parse the storyboard VTT file
* The backend generates a VTT with proxied image URLs
*/
function fetchStoryboardVTT(vttUrl) {
return fetch(vttUrl)
.then(response => {
if (!response.ok) throw new Error('Failed to fetch storyboard VTT');
return response.text();
})
.then(vttText => {
console.log('Fetched storyboard VTT, length:', vttText.length);
const lines = vttText.split('\n');
const images = [];
let currentEntry = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Parse timestamp line: 00:00:00.000 --> 00:00:10.000
if (line.includes('-->')) {
const timeMatch = line.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const ms = parseInt(timeMatch[4]);
currentEntry = {
time: hours * 3600 + minutes * 60 + seconds + ms / 1000
};
}
}
// Parse image URL with crop parameters: /url#xywh=x,y,w,h
else if (line.includes('#xywh=') && currentEntry) {
const [urlPart, paramsPart] = line.split('#xywh=');
const [x, y, width, height] = paramsPart.split(',').map(Number);
currentEntry.imageUrl = urlPart;
currentEntry.x = x;
currentEntry.y = y;
currentEntry.width = width;
currentEntry.height = height;
images.push(currentEntry);
currentEntry = null;
}
}
console.log('Parsed', images.length, 'storyboard frames');
return images;
});
}
/**
* Format time as MM:SS or H:MM:SS
*/
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
* Find the closest storyboard frame for a given time
*/
function findFrameAtTime(time) {
if (!storyboardImages.length) return null;
// Binary search for efficiency
let left = 0;
let right = storyboardImages.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const frame = storyboardImages[mid];
if (time >= frame.time && time < (storyboardImages[mid + 1]?.time || Infinity)) {
return frame;
} else if (time < frame.time) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Return closest frame
return storyboardImages[Math.min(left, storyboardImages.length - 1)];
}
/**
* Detect browser
*/
function getBrowser() {
const ua = navigator.userAgent;
if (ua.indexOf('Firefox') > -1) return 'firefox';
if (ua.indexOf('Chrome') > -1) return 'chrome';
if (ua.indexOf('Safari') > -1) return 'safari';
return 'other';
}
/**
* Detect the progress bar position in native video element
* Different browsers have different control layouts
*/
function detectProgressBar() {
if (!video) return null;
const rect = video.getBoundingClientRect();
const browser = getBrowser();
let progressBarArea;
switch(browser) {
case 'firefox':
// Firefox: La barra de progreso está en la parte inferior pero más delgada
// Normalmente ocupa solo unos 20-25px de altura y está centrada
progressBarArea = {
top: rect.bottom - 30, // Área más pequeña para Firefox
bottom: rect.bottom - 5, // Dejamos espacio para otros controles
left: rect.left + 60, // Firefox tiene botones a la izquierda (play, volumen)
right: rect.right - 10, // Y a la derecha (fullscreen, etc)
height: 25
};
break;
case 'chrome':
default:
// Chrome: La barra de progreso ocupa un área más grande
progressBarArea = {
top: rect.bottom - 50,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
height: 50
};
break;
}
return progressBarArea;
}
/**
* Check if mouse is over the progress bar area
*/
function isOverProgressBar(mouseX, mouseY) {
if (!progressBarRect) return false;
return mouseX >= progressBarRect.left &&
mouseX <= progressBarRect.right &&
mouseY >= progressBarRect.top &&
mouseY <= progressBarRect.bottom;
}
/**
* Initialize preview elements
*/
function initPreviewElements() {
video = document.getElementById('js-video-player');
if (!video) {
console.error('Video element not found');
return;
}
console.log('Video element found, browser:', getBrowser());
// Create preview element
previewElement = document.createElement('div');
previewElement.className = 'storyboard-preview';
previewElement.style.cssText = `
position: fixed;
display: none;
pointer-events: none;
z-index: 10000;
background: #000;
border: 2px solid #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
`;
// Create tooltip element
tooltipElement = document.createElement('div');
tooltipElement.className = 'storyboard-tooltip';
tooltipElement.style.cssText = `
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #fff;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
font-family: Arial, sans-serif;
white-space: nowrap;
pointer-events: none;
`;
previewElement.appendChild(tooltipElement);
document.body.appendChild(previewElement);
// Update progress bar position on mouse move
video.addEventListener('mousemove', updateProgressBarPosition);
}
/**
* Update progress bar position detection
*/
function updateProgressBarPosition() {
progressBarRect = detectProgressBar();
}
/**
* Handle mouse move - only show preview when over progress bar area
*/
function handleMouseMove(e) {
if (!video || !storyboardImages.length) return;
// Update progress bar position on each move
progressBarRect = detectProgressBar();
// Only show preview if mouse is over the progress bar area
if (!isOverProgressBar(e.clientX, e.clientY)) {
if (previewElement) previewElement.style.display = 'none';
return;
}
// Calculate position within the progress bar
const progressBarWidth = progressBarRect.right - progressBarRect.left;
let xInProgressBar = e.clientX - progressBarRect.left;
// Adjust for Firefox's left offset
const browser = getBrowser();
if (browser === 'firefox') {
// Ajustar el rango para que coincida mejor con la barra real
xInProgressBar = Math.max(0, Math.min(xInProgressBar, progressBarWidth));
}
const percentage = Math.max(0, Math.min(1, xInProgressBar / progressBarWidth));
const time = percentage * video.duration;
const frame = findFrameAtTime(time);
if (!frame) return;
// Preview dimensions
const previewWidth = 160;
const previewHeight = 90;
const offsetFromCursor = 10;
// Position above the cursor
let previewTop = e.clientY - previewHeight - offsetFromCursor;
// If preview would go above the video, position below the cursor
const videoRect = video.getBoundingClientRect();
if (previewTop < videoRect.top) {
previewTop = e.clientY + offsetFromCursor;
}
// Keep preview within horizontal bounds
let left = e.clientX - (previewWidth / 2);
// Ajustes específicos para Firefox
if (browser === 'firefox') {
// En Firefox, la barra no llega hasta los extremos
const minLeft = progressBarRect.left + 10;
const maxLeft = progressBarRect.right - previewWidth - 10;
left = Math.max(minLeft, Math.min(left, maxLeft));
} else {
left = Math.max(videoRect.left, Math.min(left, videoRect.right - previewWidth));
}
// Apply all styles
previewElement.style.cssText = `
display: block;
position: fixed;
left: ${left}px;
top: ${previewTop}px;
width: ${previewWidth}px;
height: ${previewHeight}px;
background-image: url('${frame.imageUrl}');
background-position: -${frame.x}px -${frame.y}px;
background-size: auto;
background-repeat: no-repeat;
border: 2px solid #fff;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 10000;
pointer-events: none;
`;
tooltipElement.textContent = formatTime(time);
}
/**
* Handle mouse leave video
*/
function handleMouseLeave() {
if (previewElement) {
previewElement.style.display = 'none';
}
}
/**
* Initialize storyboard preview
*/
function init() {
console.log('Initializing storyboard preview...');
// Check if storyboard URL is available
if (typeof storyboard_url === 'undefined' || !storyboard_url) {
console.log('No storyboard URL available');
return;
}
console.log('Storyboard URL:', storyboard_url);
// Fetch the proxied VTT file from backend
fetchStoryboardVTT(storyboard_url)
.then(images => {
storyboardImages = images;
console.log('Loaded', images.length, 'storyboard images');
if (images.length === 0) {
console.log('No storyboard images parsed');
return;
}
initPreviewElements();
// Add event listeners to video
video.addEventListener('mousemove', handleMouseMove);
video.addEventListener('mouseleave', handleMouseLeave);
console.log('Storyboard preview initialized for', getBrowser());
})
.catch(err => {
console.error('Failed to load storyboard:', err);
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -95,7 +95,11 @@ if (data.playlist && data.playlist['id'] !== null) {
// Autoplay
if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
(function() {
if (data.settings.related_videos_mode === 0 && data.playlist === null) {
return;
}
let playability_error = !!data.playability_error;
let isPlaylist = false;
if (data.playlist !== null && data.playlist['current_index'] !== null)
@@ -155,7 +159,10 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
if(!playability_error){
// play the video if autoplay is on
if(autoplayEnabled){
video.play();
video.play().catch(function(e) {
// Autoplay blocked by browser - ignore silently
console.log('Autoplay blocked:', e.message);
});
}
}
@@ -197,4 +204,4 @@ if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
window.setTimeout(nextVideo, nextVideoDelay);
}
}
}
})();

View File

@@ -0,0 +1,323 @@
const video = document.getElementById('js-video-player');
window.hls = null;
let hls = null;
// ===========
// HLS NATIVE
// ===========
function initHLSNative(manifestUrl) {
if (!manifestUrl) {
console.error('No HLS manifest URL provided');
return;
}
console.log('Initializing native HLS player with manifest:', manifestUrl);
if (hls) {
window.hls = null;
hls.destroy();
hls = null;
}
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
window.hls = hls;
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
console.log('Native manifest parsed');
console.log('Levels:', data.levels.length);
const qualitySelect = document.getElementById('quality-select');
if (qualitySelect && data.levels?.length) {
qualitySelect.innerHTML = '<option value="-1">Auto</option>';
const sorted = [...data.levels].sort((a, b) => b.height - a.height);
const seen = new Set();
sorted.forEach(level => {
if (!seen.has(level.height)) {
seen.add(level.height);
const i = data.levels.indexOf(level);
const opt = document.createElement('option');
opt.value = i;
opt.textContent = level.height + 'p';
qualitySelect.appendChild(opt);
}
});
// Set initial quality from settings
if (typeof window.data !== 'undefined' && window.data.settings) {
const defaultRes = window.data.settings.default_resolution;
if (defaultRes !== 'auto' && defaultRes) {
const target = parseInt(defaultRes);
let bestLevel = -1;
let bestHeight = 0;
for (let i = 0; i < hls.levels.length; i++) {
const h = hls.levels[i].height;
if (h <= target && h > bestHeight) {
bestHeight = h;
bestLevel = i;
}
}
if (bestLevel !== -1) {
hls.currentLevel = bestLevel;
qualitySelect.value = bestLevel;
console.log('Starting at resolution:', bestHeight + 'p');
}
}
}
}
});
hls.on(Hls.Events.ERROR, function(_, data) {
if (data.fatal) {
console.error('HLS fatal error:', data.type, data.details);
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
hls.destroy();
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = manifestUrl;
} else {
console.error('HLS not supported');
}
}
// ======
// INIT
// ======
function initPlayer() {
console.log('Init native player');
if (typeof hls_manifest_url === 'undefined' || !hls_manifest_url) {
console.error('No manifest URL');
return;
}
initHLSNative(hls_manifest_url);
const qualitySelect = document.getElementById('quality-select');
if (qualitySelect) {
qualitySelect.addEventListener('change', function () {
const level = parseInt(this.value);
if (hls) {
hls.currentLevel = level;
console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p');
}
});
}
}
// DOM READY
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPlayer);
} else {
initPlayer();
}
// =============
// AUDIO TRACKS
// =============
document.addEventListener('DOMContentLoaded', function() {
const audioTrackSelect = document.getElementById('audio-track-select');
if (audioTrackSelect) {
audioTrackSelect.addEventListener('change', function() {
const trackId = this.value;
if (hls && hls.audioTracks) {
const index = hls.audioTracks.findIndex(t =>
t.lang === trackId || t.name === trackId
);
if (index !== -1) {
hls.audioTrack = index;
console.log('Audio track changed to:', index);
}
}
});
}
if (hls) {
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_, data) => {
console.log('Audio tracks:', data.audioTracks);
// Populate audio track select if needed
if (audioTrackSelect && data.audioTracks.length > 0) {
audioTrackSelect.innerHTML = '<option value="">Select audio track</option>';
data.audioTracks.forEach(track => {
const option = document.createElement('option');
option.value = track.lang || track.name;
option.textContent = track.name || track.lang || `Track ${track.id}`;
audioTrackSelect.appendChild(option);
});
audioTrackSelect.disabled = false;
}
});
}
});
// ============
// START TIME
// ============
if (typeof data !== 'undefined' && data.time_start != 0 && video) {
video.addEventListener('loadedmetadata', function() {
video.currentTime = data.time_start;
});
}
// ==============
// SPEED CONTROL
// ==============
let speedInput = document.getElementById('speed-control');
if (speedInput) {
speedInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') {
let speed = parseFloat(speedInput.value);
if(!isNaN(speed)){
video.playbackRate = speed;
}
}
});
}
// =========
// Autoplay
// =========
(function() {
if (typeof data === 'undefined' || (data.settings.related_videos_mode === 0 && data.playlist === null)) {
return;
}
let playability_error = !!data.playability_error;
let isPlaylist = false;
if (data.playlist !== null && data.playlist['current_index'] !== null)
isPlaylist = true;
// read cookies on whether to autoplay
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
let cookieValue;
let playlist_id;
if (isPlaylist) {
// from https://stackoverflow.com/a/6969486
function escapeRegExp(string) {
// $& means the whole matched string
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
playlist_id = data.playlist['id'];
playlist_id = escapeRegExp(playlist_id);
cookieValue = document.cookie.replace(new RegExp(
'(?:(?:^|.*;\\s*)autoplay_'
+ playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$'
), '$1');
} else {
cookieValue = document.cookie.replace(new RegExp(
'(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$'
),'$1');
}
let autoplayEnabled = 0;
if(cookieValue.length === 0){
autoplayEnabled = 0;
} else {
autoplayEnabled = Number(cookieValue);
}
// check the checkbox if autoplay is on
let checkbox = document.querySelector('.autoplay-toggle');
if(autoplayEnabled){
checkbox.checked = true;
}
// listen for checkbox to turn autoplay on and off
let cookie = 'autoplay'
if (isPlaylist)
cookie += '_' + playlist_id;
checkbox.addEventListener( 'change', function() {
if(this.checked) {
autoplayEnabled = 1;
document.cookie = cookie + '=1; SameSite=Strict';
} else {
autoplayEnabled = 0;
document.cookie = cookie + '=0; SameSite=Strict';
}
});
if(!playability_error){
// play the video if autoplay is on
if(autoplayEnabled){
video.play().catch(function(e) {
// Autoplay blocked by browser - ignore silently
console.log('Autoplay blocked:', e.message);
});
}
}
// determine next video url
let nextVideoUrl;
if (isPlaylist) {
let currentIndex = data.playlist['current_index'];
if (data.playlist['current_index']+1 == data.playlist['items'].length)
nextVideoUrl = null;
else
nextVideoUrl = data.playlist['items'][data.playlist['current_index']+1]['url'];
// scroll playlist to proper position
// item height + gap == 100
let pl = document.querySelector('.playlist-videos');
pl.scrollTop = 100*currentIndex;
} else {
if (data.related.length === 0)
nextVideoUrl = null;
else
nextVideoUrl = data.related[0]['url'];
}
let nextVideoDelay = 1000;
// go to next video when video ends
// https://stackoverflow.com/a/2880950
if (nextVideoUrl) {
if(playability_error){
videoEnded();
} else {
video.addEventListener('ended', videoEnded, false);
}
function nextVideo(){
if(autoplayEnabled){
window.location.href = nextVideoUrl;
}
}
function videoEnded(e) {
window.setTimeout(nextVideo, nextVideoDelay);
}
}
})();

View File

@@ -44,13 +44,14 @@ e.g. Firefox playback speed options */
.plyr__controls {
display: flex;
justify-content: center;
padding-bottom: 0px;
}
.plyr__progress__container {
position: absolute;
bottom: 0;
width: 100%;
margin-bottom: -10px;
margin-bottom: -5px;
}
.plyr__controls .plyr__controls__item:first-child {
@@ -72,6 +73,120 @@ e.g. Firefox playback speed options */
margin-bottom: 50px;
}
/*
* Plyr Custom Controls
*/
.plyr__control svg.hls_audio_icon,
.plyr__control svg.hls_quality_icon {
fill: none;
}
.plyr__control[data-plyr="quality-custom"],
.plyr__control[data-plyr="audio-custom"] {
cursor: pointer;
}
.plyr__control[data-plyr="quality-custom"]:hover,
.plyr__control[data-plyr="audio-custom"]:hover {
background: rgba(255, 255, 255, 0.2);
}
/*
* Custom styles for dropdown controls
*/
.plyr__control--custom {
padding: 0;
}
/* Quality and Audio containers */
#plyr-quality-container,
#plyr-audio-container {
position: relative;
display: inline-flex;
align-items: center;
}
/* Quality and Audio buttons */
#plyr-quality-container .plyr__control,
#plyr-audio-container .plyr__control {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Text labels */
#plyr-quality-text,
#plyr-audio-text {
font-size: 12px;
margin-left: 2px;
}
/* Dropdowns */
.plyr-quality-dropdown,
.plyr-audio-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: #E6E6E6;
color: #23282f;
border-radius: 4px;
padding: 4px 6px;
min-width: 90px;
display: none;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(0, 0, 0, 0.08);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
max-height: 320px;
overflow-y: auto;
}
/* Audio dropdown needs slightly wider */
.plyr-audio-dropdown {
min-width: 120px;
}
/* Dropdown options */
.plyr-quality-option,
.plyr-audio-option {
padding: 6px 16px;
margin-bottom: 2px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
color: #23282f;
white-space: nowrap;
text-align: left;
}
/* Active/selected option */
.plyr-quality-option[data-active="true"],
.plyr-audio-option[data-active="true"] {
background: #00b3ff;
color: #FFF;
font-weight: 500;
border-radius: 4px;
}
/* Hover state */
.plyr-quality-option:hover,
.plyr-audio-option:hover {
background: #00b3ff;
color: #FFF;
font-weight: 500;
border-radius: 4px;
}
/* No audio tracks message */
.plyr-audio-no-tracks {
padding: 6px 16px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
}
/*
* End custom styles
*/