class VideoPlayer {
static defaultConfig = {
selectors: {
container: '.shifoo-video-player',
video: '#video-player',
controls: {
play: '#playBtn',
pause: '#pauseBtn',
stop: '#stopBtn',
seekBack: '#seekBackBtn',
seekForward: '#seekForwardBtn',
fullscreen: '#fullscreenBtn',
mute: '#muteBtn',
cc: '#ccBtn',
quality: '.quality-btn',
subDub: '.sub-dub-control .sub-dub-btn'
},
displays: {
timeCurrent: '.time-current',
timeTotal: '.time-total'
},
sliders: {
seek: {
slider: '.seek-slider',
fill: '.seek-fill',
buffer: '.seek-buffer',
thumb: '.seek-thumb'
},
volume: {
slider: '.volume-slider',
fill: '.volume-track .volume-fill',
thumb: '.volume-thumb'
}
},
menus: {
quality: '.quality-menu',
subDub: '.sub-dub-menu',
caption: '.caption-menu'
},
subtitles: {
container: '#custom-subtitles'
}
},
hls: {
debug: false,
capLevelToPlayerSize: true,
defaultAudioCodec: 'mp4a.40.2'
},
subtitles: {
default: {
fontSize: 24,
strokeWidth: 4,
padding: 4
},
fullscreen: {
fontSize: 48,
strokeWidth: 8,
padding: 8
}
},
keyboard: {
enabled: true,
shortcuts: {
space: 'togglePlayPause',
arrowleft: 'seekBackward',
arrowright: 'seekForward',
f: 'toggleFullscreen',
m: 'toggleMute',
arrowup: ['changeVolume', 0.1],
arrowdown: ['changeVolume', -0.1],
'+': ['changeSubtitleSize', 1],
'=': ['changeSubtitleSize', 1],
'-': ['changeSubtitleSize', -1],
'0': 'resetSubtitleSize'
}
},
source: {
url: '',
type: 'hls', // 'hls' or 'video'
tracks: [] // Array of subtitle tracks
}
};
static STORAGE_KEYS = {
VOLUME: 'videoplayer_volume',
QUALITY: 'videoplayer_quality'
};
constructor(config = {}) {
this.config = this.mergeConfig(VideoPlayer.defaultConfig, config);
this.initializeElements();
this.setupSource();
this.setupEventListeners();
this.setupSubtitles();
this.setupFullscreenHandling();
this.setupVideoInteractions();
this.setupBufferingIndicator();
this.loadVolume();
if (this.config.keyboard.enabled) {
this.setupKeyboardControls();
}
this.initializeSubtitleSize();
}
mergeConfig(defaultConfig, userConfig) {
const merged = { ...defaultConfig };
const merge = (target, source) => {
Object.keys(source).forEach(key => {
if (source[key] instanceof Object && !Array.isArray(source[key])) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
});
};
merge(merged, userConfig);
return merged;
}
initializeElements() {
const s = this.config.selectors;
this.elements = {
container: document.querySelector(s.container),
video: document.querySelector(s.video),
controls: {},
displays: {},
sliders: {
seek: {},
volume: {}
},
menus: {},
subtitles: {}
};
// Initialize controls
Object.entries(s.controls).forEach(([key, selector]) => {
this.elements.controls[key] = document.querySelector(selector);
});
// Initialize displays
Object.entries(s.displays).forEach(([key, selector]) => {
this.elements.displays[key] = document.querySelector(selector);
});
// Initialize sliders
Object.entries(s.sliders).forEach(([type, selectors]) => {
Object.entries(selectors).forEach(([key, selector]) => {
this.elements.sliders[type][key] = document.querySelector(selector);
});
});
// Initialize menus
Object.entries(s.menus).forEach(([key, selector]) => {
this.elements.menus[key] = document.querySelector(selector);
});
// Initialize subtitles container
this.elements.subtitles.container = document.querySelector(s.subtitles.container);
}
initializeSubtitleSize() {
const isFullscreen = !!document.fullscreenElement;
const config = isFullscreen ? this.config.subtitles.fullscreen : this.config.subtitles.default;
this.subtitleStyles = {
fontSize: `${config.fontSize}px`,
strokeWidth: `${config.strokeWidth}px`,
padding: `${config.padding}px`
};
const span = this.elements.subtitles.container.querySelector('span');
if (span) {
span.style.fontSize = this.subtitleStyles.fontSize;
span.style.webkitTextStroke = `${this.subtitleStyles.strokeWidth} black`;
span.style.padding = this.subtitleStyles.padding;
}
}
setupSource() {
const { url, type } = this.config.source;
if (!url) return;
if (type === 'hls') {
if (!Hls.isSupported()) return;
this.hls = new Hls(this.config.hls);
this.hls.attachMedia(this.elements.video);
this.hls.loadSource(url);
this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
this.setupQualityMenu(data.levels);
});
} else {
this.elements.video.src = url;
}
}
setupEventListeners() {
// Video controls
this.elements.controls.play.addEventListener('click', () => this.elements.video.play());
this.elements.controls.pause.addEventListener('click', () => this.elements.video.pause());
this.elements.controls.stop.addEventListener('click', () => {
this.elements.video.pause();
this.elements.video.currentTime = 0;
});
this.elements.controls.seekBack.addEventListener('click', () => this.seekBackward());
this.elements.controls.seekForward.addEventListener('click', () => this.seekForward());
this.elements.controls.fullscreen.addEventListener('click', () => this.toggleFullscreen());
this.elements.controls.mute.addEventListener('click', () => this.toggleMute());
// Video events
this.elements.video.addEventListener('timeupdate', () => this.updateTimeDisplay());
this.elements.video.addEventListener('loadedmetadata', () => {
this.elements.displays.timeTotal.textContent = this.formatTime(this.elements.video.duration);
});
this.elements.video.addEventListener('progress', () => this.updateBuffer());
// Setup slider events
this.setupSliderEvents();
// Setup menu events
this.setupMenuEvents();
}
setupSliderEvents() {
// Seek slider
this.elements.sliders.seek.slider.addEventListener('input', (e) => {
const value = e.target.value;
this.elements.video.currentTime = (value / 100) * this.elements.video.duration;
this.updateSeekDisplay(value);
});
// Volume slider
this.elements.sliders.volume.slider.addEventListener('input', (e) => {
const value = e.target.value;
this.updateVolume(value / 100);
});
}
setupMenuEvents() {
const closeMenus = (except) => {
Object.entries(this.elements.menus).forEach(([key, menu]) => {
try {
if (key !== except) menu.classList.remove('show');
} catch (e) { }
});
};
// Quality menu
this.elements.controls.quality?.addEventListener('click', () => {
this.elements.menus.quality.classList.toggle('show');
closeMenus('quality');
});
// Sub/Dub menu
this.elements.controls.subDub?.addEventListener('click', () => {
this.elements.menus.subDub.classList.toggle('show');
closeMenus('subDub');
});
// Caption menu
this.elements.controls.cc?.addEventListener('click', () => {
this.elements.menus.caption.classList.toggle('show');
closeMenus('caption');
});
// Close menus when clicking outside
document.addEventListener('click', (e) => {
try {
if (!e.target.closest('.quality-control')) this.elements.menus.quality.classList.remove('show');
} catch (e) { }
try {
if (!e.target.closest('.sub-dub-control')) this.elements.menus.subDub.classList.remove('show');
} catch (e) { }
try {
if (!e.target.closest('#ccBtn')) this.elements.menus.caption.classList.remove('show');
} catch (e) { }
});
}
saveVolume(volume) {
try {
localStorage.setItem(VideoPlayer.STORAGE_KEYS.VOLUME, volume);
} catch (e) { }
}
loadVolume() {
try {
const savedVolume = localStorage.getItem(VideoPlayer.STORAGE_KEYS.VOLUME);
if (savedVolume !== null) {
this.updateVolume(parseFloat(savedVolume));
}
} catch (e) { }
}
saveQuality(quality) {
try {
localStorage.setItem(VideoPlayer.STORAGE_KEYS.QUALITY, quality);
} catch (e) { }
}
loadQuality() {
try {
const savedQuality = localStorage.getItem(VideoPlayer.STORAGE_KEYS.QUALITY);
if (savedQuality !== null && this.hls) {
this.hls.currentLevel = parseInt(savedQuality);
// Update quality button text
const qualityButton = this.elements.controls.quality;
const qualityText = savedQuality === '-1' ? 'Auto' :
`${this.hls.levels[savedQuality].height}p`;
qualityButton.innerHTML = this.getQualityButtonHTML(qualityText);
}
} catch (e) { }
}
getQualityButtonHTML(text) {
return `
${text}`;
}
setupBufferingIndicator() {
const playerContainer = this.elements.video.closest('.shifoo-video-player-content');
let loadingTimeout;
const isBuffering = () => {
const video = this.elements.video;
// If seeking or initial load
if (video.seeking || video.readyState < 3) return true;
// If playing but not enough data
if (!video.paused && video.currentTime > 0) {
// Check if we have data for the current position
for (let i = 0; i < video.buffered.length; i++) {
if (video.currentTime >= video.buffered.start(i) &&
video.currentTime <= video.buffered.end(i)) {
// Check if we have enough buffer ahead
const aheadBuffer = video.buffered.end(i) - video.currentTime;
if (aheadBuffer < 0.5) return true; // Less than 0.5 seconds ahead
return false;
}
}
return true; // Current time not in any buffer range
}
return false;
};
const showLoading = () => {
if (loadingTimeout) clearTimeout(loadingTimeout);
loadingTimeout = setTimeout(() => {
if (isBuffering()) {
playerContainer.classList.add('video-loading');
}
}, 100);
};
const hideLoading = () => {
if (loadingTimeout) clearTimeout(loadingTimeout);
loadingTimeout = setTimeout(() => {
if (!isBuffering()) {
playerContainer.classList.remove('video-loading');
}
}, 100);
};
// Video events
this.elements.video.addEventListener('waiting', showLoading);
this.elements.video.addEventListener('canplay', hideLoading);
this.elements.video.addEventListener('playing', hideLoading);
this.elements.video.addEventListener('progress', showLoading); // Check on data load
this.elements.video.addEventListener('timeupdate', () => {
if (isBuffering()) showLoading();
else hideLoading();
});
this.elements.video.addEventListener('seeked', hideLoading);
this.elements.video.addEventListener('stalled', showLoading);
// HLS specific events
if (this.hls) {
this.hls.on(Hls.Events.FRAG_LOADING, showLoading);
this.hls.on(Hls.Events.FRAG_BUFFERED, hideLoading);
this.hls.on(Hls.Events.ERROR, showLoading);
}
// Initial loading state
if (!this.elements.video.readyState || this.elements.video.readyState < 3) {
playerContainer.classList.add('video-loading');
}
}
setupQualityMenu(levels) {
this.elements.menus.quality.innerHTML = `
${levels.map((level, index) => `
`).join('')}
`;
this.elements.menus.quality.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const quality = parseInt(e.target.dataset.quality);
this.hls.currentLevel = quality;
this.elements.controls.quality.innerHTML = this.getQualityButtonHTML(e.target.textContent);
this.elements.menus.quality.classList.remove('show');
this.saveQuality(quality); // Save quality setting
}
});
// Load saved quality after menu setup
this.loadQuality();
}
setupSubtitles() {
const { tracks } = this.config.source;
if (!tracks || !tracks.length) return;
// Clear existing tracks
while (this.elements.video.textTracks.length > 0) {
this.elements.video.removeChild(this.elements.video.textTracks[0]);
}
// Add new tracks and store them
this.subtitleTracks = tracks.map((trackInfo, index) => {
const track = document.createElement('track');
track.kind = trackInfo.kind;
track.label = trackInfo.label;
track.srclang = trackInfo.srclang || 'en';
track.src = trackInfo.file;
if (trackInfo.default) {
track.default = true;
}
this.elements.video.appendChild(track);
return track;
});
// Setup custom subtitle display and initialize default track
setTimeout(() => {
const tracks = this.elements.video.textTracks;
// First hide all tracks
for (let track of tracks) {
track.mode = 'hidden';
}
// Find and initialize default track
const defaultTrackIndex = tracks.length ? this.config.source.tracks.findIndex(track => track.default) : -1;
this.currentTrackIndex = defaultTrackIndex;
if (defaultTrackIndex !== -1) {
// Set up the track immediately
this.setupTrackCueListener(defaultTrackIndex);
// Update the CC button text
const defaultTrack = this.config.source.tracks[defaultTrackIndex];
this.elements.controls.cc.innerHTML = `
${defaultTrack.label}
`;
}
}, 100);
this.setupCaptionMenu();
}
setupTrackCueListener(trackIndex) {
// Remove existing cue listeners
if (this.currentTrack) {
this.currentTrack.removeEventListener('cuechange', this.cueChangeHandler);
}
// If track index is -1 (off) or invalid, just clear subtitles
if (trackIndex === -1 || !this.elements.video.textTracks[trackIndex]) {
this.elements.subtitles.container.innerHTML = '';
return;
}
// Set up new track
this.currentTrack = this.elements.video.textTracks[trackIndex];
this.currentTrack.mode = 'showing';
// Create cue change handler
this.cueChangeHandler = (e) => {
this.elements.subtitles.container.innerHTML = '';
if (this.currentTrack.activeCues?.length > 0) {
const cue = this.currentTrack.activeCues[0];
const span = document.createElement('span');
span.innerHTML = cue.text;
Object.assign(span.style, {
fontSize: this.subtitleStyles.fontSize,
webkitTextStroke: `${this.subtitleStyles.strokeWidth} black`,
padding: this.subtitleStyles.padding
});
this.elements.subtitles.container.appendChild(span);
}
};
// Add the listener
this.currentTrack.addEventListener('cuechange', this.cueChangeHandler);
}
setupCaptionMenu() {
const { tracks } = this.config.source;
if (!tracks || !tracks.length) return;
this.elements.menus.caption.innerHTML = `
${tracks.map((track, index) => `
`).join('')}
`;
this.elements.menus.caption.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const selectedTrackIndex = e.target.dataset.track;
const trackIndex = selectedTrackIndex === 'off' ? -1 : parseInt(selectedTrackIndex);
// Update display state
this.elements.subtitles.container.style.display = trackIndex >= 0 ? 'block' : 'none';
// Hide all tracks first
Array.from(this.elements.video.textTracks).forEach(track => {
track.mode = 'hidden';
});
// Setup the new track listener
this.setupTrackCueListener(trackIndex);
// Update button text
this.elements.controls.cc.innerHTML = `
${e.target.textContent}
`;
this.elements.menus.caption.classList.remove('show');
}
});
// Activate default track if specified
const defaultTrackIndex = tracks.findIndex(track => track.default);
if (defaultTrackIndex !== -1) {
const defaultButton = this.elements.menus.caption.querySelector(`[data-track="${defaultTrackIndex}"]`);
if (defaultButton) {
defaultButton.click();
}
}
}
setupFullscreenHandling() {
let timeout;
const showControls = () => {
if (document.fullscreenElement === this.elements.container) {
this.elements.container.classList.add('controls-visible');
this.elements.container.style.cursor = 'default';
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
if (!this.isHoveringControls) {
this.elements.container.classList.remove('controls-visible');
this.elements.container.style.cursor = 'none';
}
}, 3000);
}
};
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement === this.elements.container) {
this.elements.container.addEventListener('mousemove', showControls);
showControls();
this.updateSubtitleSizeForFullscreen(true);
} else {
this.elements.container.removeEventListener('mousemove', showControls);
this.elements.container.classList.remove('controls-visible');
this.elements.container.style.cursor = 'default';
document.body.style.cursor = 'default';
this.updateSubtitleSizeForFullscreen(false);
}
});
this.isHoveringControls = false;
const controlsArea = this.elements.container.querySelector('.shifoo-video-player-controls');
controlsArea.addEventListener('mouseenter', () => {
this.isHoveringControls = true;
if (document.fullscreenElement === this.elements.container) {
this.elements.container.classList.add('controls-visible');
}
});
controlsArea.addEventListener('mouseleave', () => {
this.isHoveringControls = false;
if (document.fullscreenElement === this.elements.container) {
timeout = setTimeout(() => {
if (!this.isHoveringControls) {
this.elements.container.classList.remove('controls-visible');
}
}, 3000);
}
});
}
setupVideoInteractions() {
this.elements.video.addEventListener('click', (e) => {
if (this.isDragging) return;
this.togglePlayPause();
});
let clickTimeout;
this.elements.video.addEventListener('click', (e) => {
if (clickTimeout) {
clearTimeout(clickTimeout);
clickTimeout = null;
this.toggleFullscreen();
} else {
clickTimeout = setTimeout(() => {
clickTimeout = null;
}, 300);
}
});
this.isDragging = false;
this.elements.sliders.seek.slider.addEventListener('mousedown', () => this.isDragging = true);
document.addEventListener('mouseup', () => this.isDragging = false);
}
setupKeyboardControls() {
document.addEventListener('keydown', (e) => {
if (!this.elements.video || e.target.matches('input, textarea')) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.target !== document.body) return;
const shortcut = this.config.keyboard.shortcuts[e.key.toLowerCase()];
if (shortcut) {
e.preventDefault();
if (Array.isArray(shortcut)) {
this[shortcut[0]](...shortcut.slice(1));
} else {
this[shortcut]();
}
}
});
}
// Utility methods
formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
}
updateTimeDisplay() {
const progress = (this.elements.video.currentTime / this.elements.video.duration) * 100;
// If there is a single digit in minutes, add a leading zero
const currentTime = this.formatTime(this.elements.video.currentTime);
const singleDigitMinutes = currentTime.length === 4 && currentTime[1] === ':';
this.elements.displays.timeCurrent.textContent = singleDigitMinutes ? `0${currentTime}` : currentTime;
this.updateSeekDisplay(progress);
}
updateSeekDisplay(progress) {
this.elements.sliders.seek.fill.style.width = `${progress}%`;
this.elements.sliders.seek.slider.value = progress;
this.elements.sliders.seek.thumb.style.left = `${progress}%`;
}
updateBuffer() {
if (this.elements.video.buffered.length > 0) {
const bufferedEnd = this.elements.video.buffered.end(this.elements.video.buffered.length - 1);
const bufferedProgress = (bufferedEnd / this.elements.video.duration) * 100;
this.elements.sliders.seek.buffer.style.width = `${bufferedProgress}%`;
}
}
updateVolume(volume) {
this.elements.video.volume = volume;
this.updateVolumeSliders(volume);
this.updateMuteButton(volume > 0);
if (this.elements.video.muted && volume > 0) this.elements.video.muted = false;
this.saveVolume(volume); // Save volume setting
}
updateVolumeSliders(volume) {
const percentage = volume * 100;
this.elements.sliders.volume.fill.style.width = `${percentage}%`;
this.elements.sliders.volume.slider.value = percentage;
this.elements.sliders.volume.thumb.style.left = `${percentage}%`;
}
updateMuteButton(isAudio) {
this.elements.controls.mute.querySelector('.shifoo-video-player-icon').innerHTML = isAudio ?
'' :
'';
}
toggleFullscreen() {
if (!document.fullscreenElement) {
this.elements.container.requestFullscreen();
} else {
document.exitFullscreen();
}
}
togglePlayPause() {
if (this.elements.video.paused) {
this.elements.video.play();
} else {
this.elements.video.pause();
}
}
toggleMute() {
this.elements.video.muted = !this.elements.video.muted;
if (!this.elements.video.muted && this.elements.video.volume === 0) this.updateVolume(1);
this.updateVolumeSliders(this.elements.video.muted ? 0 : this.elements.video.volume);
this.updateMuteButton(!this.elements.video.muted);
}
seekBackward() {
this.elements.video.currentTime = Math.max(0, this.elements.video.currentTime - 10);
}
seekForward() {
this.elements.video.currentTime = Math.min(this.elements.video.duration, this.elements.video.currentTime + 10);
}
changeVolume(delta) {
const newVolume = Math.max(0, Math.min(1, this.elements.video.volume + delta));
this.updateVolume(newVolume);
}
resetSubtitleSize() {
const isFullscreen = !!document.fullscreenElement;
const config = isFullscreen ? this.config.subtitles.fullscreen : this.config.subtitles.default;
this.subtitleStyles = {
fontSize: `${config.fontSize}px`,
strokeWidth: `${config.strokeWidth}px`,
padding: `${config.padding}px`
};
const span = this.elements.subtitles.container.querySelector('span');
if (span) {
span.style.fontSize = this.subtitleStyles.fontSize;
span.style.webkitTextStroke = `${this.subtitleStyles.strokeWidth} black`;
span.style.padding = this.subtitleStyles.padding;
}
}
changeSubtitleSize(delta) {
const isFullscreen = !!document.fullscreenElement;
const config = isFullscreen ? this.config.subtitles.fullscreen : this.config.subtitles.default;
const currentSize = parseInt(this.subtitleStyles.fontSize);
const minSize = config.fontSize * 0.5;
const maxSize = config.fontSize * 1.5;
const newSize = Math.min(Math.max(currentSize + (delta * 2), minSize), maxSize);
if (newSize === currentSize) return;
const strokeSize = newSize / 6;
const paddingSize = strokeSize / 2;
this.subtitleStyles = {
fontSize: `${newSize}px`,
strokeWidth: `${strokeSize}px`,
padding: `${paddingSize}px`
};
const span = this.elements.subtitles.container.querySelector('span');
if (span) {
span.style.fontSize = this.subtitleStyles.fontSize;
span.style.webkitTextStroke = `${this.subtitleStyles.strokeWidth} black`;
span.style.padding = this.subtitleStyles.padding;
}
}
updateSubtitleSizeForFullscreen(isFullscreen) {
const config = isFullscreen ? this.config.subtitles.fullscreen : this.config.subtitles.default;
this.subtitleStyles = {
fontSize: `${config.fontSize}px`,
strokeWidth: `${config.strokeWidth}px`,
padding: `${config.padding}px`
};
const span = this.elements.subtitles.container.querySelector('span');
if (span) {
span.style.fontSize = this.subtitleStyles.fontSize;
span.style.webkitTextStroke = `${this.subtitleStyles.strokeWidth} black`;
span.style.padding = this.subtitleStyles.padding;
}
}
}