All checks were successful
CI / test (push) Successful in 45s
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.
526 lines
17 KiB
JavaScript
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();
|
|
}
|
|
})();
|