Files
yt-local/youtube/static/js/plyr.hls.start.js
Astounds b9248cfe2d
All checks were successful
CI / test (push) Successful in 45s
fix: init Plyr before HLS manifest loads to avoid bare video flash
Create Plyr instance immediately in start() so the styled player UI
appears right away. Quality and audio controls are injected once the
HLS manifest is ready, running doAdd() directly when player.ready is
already true instead of waiting on the 'ready' event that already fired.
2026-05-03 13:45:00 -05:00

526 lines
17 KiB
JavaScript

(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) {
function doAdd() {
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');
}
// Run immediately if Plyr is already ready, otherwise wait
if (player.ready) {
doAdd();
} else {
player.on('ready', doAdd);
}
}
/**
* Create custom audio tracks control in Plyr controls
*/
function addCustomAudioTracksControl(player, hlsInstance) {
function doAdd() {
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 => {
const name = (t.name || '').toLowerCase();
const lang = (t.lang || '').toLowerCase();
return name.includes('original') || lang === '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');
}
// Run immediately if Plyr is already ready, otherwise wait
if (player.ready) {
doAdd();
} else {
player.on('ready', doAdd);
}
}
/**
* 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;
}
// Initialize Plyr immediately so the player UI shows right away
// instead of a bare <video> element while the manifest loads.
const video = document.getElementById('js-video-player');
if (video) {
plyrInstance = new Plyr(video, {
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 },
});
window.plyrInstance = plyrInstance;
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;
});
}
}
try {
const hlsInstance = await initHLS(hls_manifest_url);
// Manifest is ready — add quality and audio controls
addCustomQualityControl(plyrInstance, buildQualityLabels(hlsInstance));
addCustomAudioTracksControl(plyrInstance, hlsInstance);
} catch (error) {
console.error('Failed to initialize HLS:', error);
}
}
/**
* Build quality labels from HLS levels
*/
function buildQualityLabels(hlsInstance) {
const qualityLabels = ['auto'];
if (!hlsInstance || !hlsInstance.levels) return qualityLabels;
const sortedLevels = [...hlsInstance.levels].sort((a, b) => b.height - a.height);
const seenHeights = new Set();
sortedLevels.forEach((level) => {
if (!seenHeights.has(level.height)) {
seenHeights.add(level.height);
const originalIndex = hlsInstance.levels.indexOf(level);
const label = level.height + 'p';
if (!window.hlsQualityMap[label]) {
qualityLabels.push(label);
window.hlsQualityMap[label] = originalIndex;
}
}
});
return qualityLabels;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();