feat: Video.js (qualities, css improvements)

- Settings menu content - split into separate variables for maintainability
- Only include qualities that have actual sources
- hls_info vs encodings_info
This commit is contained in:
Yiannis Christodoulou
2025-09-15 23:47:43 +03:00
parent 543085c38f
commit 5d99a4e23d
10 changed files with 700 additions and 1064 deletions
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,8 @@ class CustomChaptersOverlay extends Component {
this.createOverlay = this.createOverlay.bind(this);
this.updateCurrentChapter = this.updateCurrentChapter.bind(this);
this.toggleOverlay = this.toggleOverlay.bind(this);
this.formatTime = this.formatTime.bind(this);
this.getChapterTimeRange = this.getChapterTimeRange.bind(this);
// Initialize after player is ready
this.player().ready(() => {
@@ -27,6 +29,21 @@ class CustomChaptersOverlay extends Component {
});
}
formatTime(seconds) {
const totalSec = Math.max(0, Math.floor(seconds));
const hh = Math.floor(totalSec / 3600);
const mm = Math.floor((totalSec % 3600) / 60);
const ss = totalSec % 60;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}
getChapterTimeRange(chapter) {
const startTime = this.formatTime(chapter.startTime);
const endTime = this.formatTime(chapter.endTime || chapter.startTime);
return `${startTime} - ${endTime}`;
}
createOverlay() {
if (!this.chaptersData || this.chaptersData.length === 0) {
console.log('⚠ No chapters data available for overlay');
@@ -46,10 +63,16 @@ class CustomChaptersOverlay extends Component {
height: 100%;
z-index: 9999;
display: none;
pointer-events: none;
pointer-events: auto;
background: rgba(0, 0, 0, 0.35);
`;
this.overlay.addEventListener('click', (event) => {
if (event.target === this.overlay) {
this.closeOverlay();
}
});
const container = document.createElement('div');
container.className = 'video-chapter';
container.style.cssText = `
@@ -129,8 +152,10 @@ class CustomChaptersOverlay extends Component {
sub.className = 'meta-sub';
const dynamic = document.createElement('span');
dynamic.className = 'meta-dynamic';
dynamic.textContent = this.channelName;
const chapterTimeRange = this.getChapterTimeRange(chapter);
dynamic.textContent = chapterTimeRange;
dynamic.setAttribute('data-duration', timeStr);
dynamic.setAttribute('data-time-range', chapterTimeRange);
sub.appendChild(dynamic);
meta.appendChild(titleEl);
meta.appendChild(sub);
@@ -215,7 +240,7 @@ class CustomChaptersOverlay extends Component {
} else {
item.classList.remove('selected');
if (handle) handle.textContent = String(index + 1);
if (dynamic) dynamic.textContent = this.channelName;
if (dynamic) dynamic.textContent = dynamic.getAttribute('data-time-range') || this.getChapterTimeRange(chapter);
}
});
}
@@ -232,11 +257,30 @@ class CustomChaptersOverlay extends Component {
} else {
el.classList.remove('selected');
if (handle) handle.textContent = String(idx + 1);
if (dynamic) dynamic.textContent = this.channelName;
if (dynamic) {
const timeRange = dynamic.getAttribute('data-time-range');
if (timeRange) {
dynamic.textContent = timeRange;
} else {
// Fallback: calculate time range from chapters data
const chapter = this.chaptersData[idx];
if (chapter) {
dynamic.textContent = this.getChapterTimeRange(chapter);
}
}
}
}
});
}
closeOverlay() {
if (this.overlay) {
this.overlay.style.display = 'none';
const el = this.player().el();
if (el) el.classList.remove('chapters-open');
}
}
dispose() {
if (this.overlay) {
this.overlay.remove();
@@ -17,6 +17,7 @@ class CustomSettingsMenu extends Component {
this.subtitlesSubmenu = null;
this.userPreferences = options?.userPreferences || new UserPreferences();
this.providedQualities = options?.qualities || null;
this.hasSubtitles = options?.hasSubtitles || false;
// Bind methods
this.createSettingsButton = this.createSettingsButton.bind(this);
@@ -153,10 +154,10 @@ class CustomSettingsMenu extends Component {
activeQuality?.label ||
(currentQuality ? String(currentQuality) : "Auto");
// Settings menu content
this.settingsOverlay.innerHTML = `
<div class="settings-header">Settings</div>
// Settings menu content - split into separate variables for maintainability
const settingsHeader = `<div class="settings-header">Settings</div>`;
const playbackSpeedSection = `
<div class="settings-item" data-setting="playback-speed">
<span class="settings-left">
<span class="vjs-icon-placeholder settings-item-svg">
@@ -167,8 +168,9 @@ class CustomSettingsMenu extends Component {
<span class="current-speed">${playbackRateLabel}</span>
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
</span>
</div>
</div>`;
const qualitySection = `
<div class="settings-item" data-setting="quality">
<span class="settings-left">
<span class="vjs-icon-placeholder settings-item-svg">
@@ -179,8 +181,9 @@ class CustomSettingsMenu extends Component {
<span class="current-quality">${qualityLabelHTML}</span>
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
</span>
</div>
</div>`;
const subtitlesSection = `
<div class="settings-item" data-setting="subtitles">
<span class="settings-left">
<span class="vjs-icon-placeholder settings-item-svg">
@@ -191,8 +194,17 @@ class CustomSettingsMenu extends Component {
<span class="current-subtitles">${currentSubtitleLabel}</span>
<span class="vjs-icon-placeholder vjs-icon-navigate-next"></span>
</span>
</div>
`;
</div>`;
// Build the complete settings overlay
this.settingsOverlay.innerHTML = settingsHeader;
this.settingsOverlay.innerHTML += playbackSpeedSection;
this.settingsOverlay.innerHTML += qualitySection;
// Check if subtitles are available
if (this.hasSubtitles) {
this.settingsOverlay.innerHTML += subtitlesSection;
}
// Create speed submenu
this.createSpeedSubmenu();
@@ -459,13 +471,8 @@ class CustomSettingsMenu extends Component {
return this.sortAndDecorateQualities(mapped, desiredOrder);
}
// Default fallback
// Build full ordered list without src so UI is consistent; switching will require src in JSON
const fallback = desiredOrder.map((v) => ({
label: v === "auto" ? "Auto" : v,
value: v,
}));
return this.sortAndDecorateQualities(fallback, desiredOrder);
// Default fallback - return empty array if no valid sources found
return [];
}
sortAndDecorateQualities(list, desiredOrder) {
@@ -473,7 +480,11 @@ class CustomSettingsMenu extends Component {
const i = desiredOrder.indexOf(String(val).toLowerCase());
return i === -1 ? 999 : i;
};
const decorated = list
// Only include qualities that have actual sources
const validQualities = list.filter(q => q.src);
const decorated = validQualities
.map((q) => {
const val = (q.value || q.label || "").toString().toLowerCase();
const baseLabel = q.label || q.value || "";
@@ -485,20 +496,6 @@ class CustomSettingsMenu extends Component {
})
.sort((a, b) => orderIndex(a.value) - orderIndex(b.value));
// Ensure all desired labels appear at least once (even if not provided), for consistent menu
const have = new Set(decorated.map((q) => q.value));
desiredOrder.forEach((val) => {
if (!have.has(val)) {
const baseLabel = val === "auto" ? "Auto" : val;
const displayLabel =
val === "1080p"
? `${baseLabel} <sup class="hd-badge">HD</sup>`
: baseLabel;
decorated.push({ label: baseLabel, value: val, displayLabel });
}
});
// Re-sort after pushing missing
decorated.sort((a, b) => orderIndex(a.value) - orderIndex(b.value));
return decorated;
}
@@ -27,6 +27,63 @@ function VideoJSPlayer() {
? window.MEDIA_DATA
: {
data: {
__hls_info: {
"master_file": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/master.m3u8",
"480_iframe": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-1/iframes.m3u8",
"720_iframe": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-2/iframes.m3u8",
"240_iframe": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-3/iframes.m3u8",
"360_iframe": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-4/iframes.m3u8",
"480_playlist": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-1/stream.m3u8",
"720_playlist": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-2/stream.m3u8",
"240_playlist": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-3/stream.m3u8",
"360_playlist": "http://localhost/media/hls/c868df836ac34688b876e741b58ada93/media-4/stream.m3u8"
},
__original_media_url: "http://localhost/media/original/user/admin/c868df836ac34688b876e741b58ada93.SampleVideo_1280x720_30mb.mp4",
__encodings_info: {
"144": {},
"240": {
"h264": {
"title": "h264-240",
"url": "http://localhost/media/encoded/2/admin/c868df836ac34688b876e741b58ada93.c868df836ac34688b876e741b58ada93.SampleVideo_1280x720_30mb.mp4.mp4",
"progress": 100,
"size": "8.2MB",
"encoding_id": 22,
"status": "success"
}
},
"360": {
"h264": {
"title": "h264-360",
"url": "http://localhost/media/encoded/3/admin/c868df836ac34688b876e741b58ada93.c868df836ac34688b876e741b58ada93.SampleVideo_1280x720_30mb.mp4.mp4",
"progress": 100,
"size": "12.3MB",
"encoding_id": 23,
"status": "success"
}
},
"480": {
"h264": {
"title": "h264-480",
"url": "http://localhost/media/encoded/13/admin/c868df836ac34688b876e741b58ada93.c868df836ac34688b876e741b58ada93.SampleVideo_1280x720_30mb.mp4.mp4",
"progress": 100,
"size": "17.4MB",
"encoding_id": 24,
"status": "success"
}
},
"720": {
"h264": {
"title": "h264-720",
"url": "http://localhost/media/encoded/10/admin/c868df836ac34688b876e741b58ada93.c868df836ac34688b876e741b58ada93.SampleVideo_1280x720_30mb.mp4.mp4",
"progress": 100,
"size": "27.4MB",
"encoding_id": 25,
"status": "success"
}
},
"1080": {},
"1440": {},
},
related_media: [
{
friendly_token: 'jgLkic37V',
@@ -564,18 +621,12 @@ function VideoJSPlayer() {
},
siteUrl: '',
nextLink: 'https://demo.mediacms.io/view?m=YjGJafibO',
chapter_data: [
{ startTime: 0, endTime: 3, text: 'Introduction' },
{ startTime: 3, endTime: 5, text: 'Overview of Marine Life' },
{ startTime: 5, endTime: 10, text: 'Coral Reef Ecosystems' },
{ startTime: 10, endTime: 14, text: 'Deep Sea Creatures' },
],
chaptersData: [
{ startTime: 0, endTime: 3, text: 'Introduction' },
{ startTime: 3, endTime: 5, text: 'Overview of Marine Life' },
{ startTime: 5, endTime: 10, text: 'Coral Reef Ecosystems' },
{ startTime: 10, endTime: 14, text: 'Deep Sea Creatures' },
{ startTime: 240, endTime: 320, text: 'Ocean Conservation' },
{ startTime: 0, endTime: 4, text: 'Introduction' },
{ startTime: 5, endTime: 10, text: 'Overview of Marine Life' },
{ startTime: 10, endTime: 15, text: 'Coral Reef Ecosystems' },
{ startTime: 15, endTime: 20, text: 'Deep Sea Creatures' },
{ startTime: 20, endTime: 30, text: 'Ocean Conservation' },
{ startTime: 320, endTime: 400, text: 'Climate Change Impact' },
{ startTime: 400, endTime: 480, text: 'Marine Protected Areas' },
{ startTime: 480, endTime: 560, text: 'Sustainable Fishing' },
@@ -600,10 +651,11 @@ function VideoJSPlayer() {
// Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
const chaptersData = mediaData.chaptersData ?? [
{ startTime: 0, endTime: 30, text: 'Introduction' },
{ startTime: 30, endTime: 90, text: 'Overview of Marine Life' },
{ startTime: 90, endTime: 180, text: 'Coral Reef Ecosystems' },
{ startTime: 180, endTime: 240, text: 'Deep Sea Creatures' },
{ startTime: 0, endTime: 4, text: 'Introduction' },
{ startTime: 5, endTime: 10, text: 'Overview of Marine Life' },
{ startTime: 10, endTime: 15, text: 'Coral Reef Ecosystems' },
{ startTime: 15, endTime: 20, text: 'Deep Sea Creatures' },
{ startTime: 20, endTime: 30, text: 'Ocean Conservation' },
{ startTime: 240, endTime: 320, text: 'Ocean Conservation' },
{ startTime: 320, endTime: 400, text: 'Climate Change Impact' },
{ startTime: 400, endTime: 480, text: 'Marine Protected Areas' },
@@ -625,33 +677,102 @@ function VideoJSPlayer() {
// Get video data from mediaData
const currentVideo = useMemo(
() => ({
id: mediaData.data?.friendly_token || 'default-video',
title: mediaData.data?.title || 'Video',
poster: mediaData.siteUrl + mediaData.data?.poster_url || '',
previewSprite: mediaData?.previewSprite || {},
related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null,
sources: mediaData.data?.original_media_url
? [
{
src: mediaData.siteUrl + mediaData.data.original_media_url,
type: 'video/mp4',
},
]
: [
() => {
// Get video sources based on available data
const getVideoSources = () => {
// Check if HLS info is available and not empty
if (mediaData.data?.hls_info && mediaData.data.hls_info.master_file) {
// Use master file as the primary source (auto quality)
return [
{
src: mediaData.siteUrl + mediaData.data.hls_info.master_file,
type: 'application/x-mpegURL', // HLS MIME type
label: 'Auto',
},
];
}
// Fallback to encoded qualities if available
if (mediaData.data?.encodings_info) {
const sources = [];
const encodings = mediaData.data.encodings_info;
// Get available qualities dynamically from encodings_info
const availableQualities = Object.keys(encodings)
.filter(quality => encodings[quality] && encodings[quality].h264 && encodings[quality].h264.url)
.sort((a, b) => parseInt(b) - parseInt(a)); // Sort descending (highest first)
for (const quality of availableQualities) {
sources.push({
src: mediaData.siteUrl + encodings[quality].h264.url,
type: 'video/mp4',
label: `${quality}p`,
});
}
if (sources.length > 0) {
return sources;
}
}
// Final fallback to original media URL or sample video
if (mediaData.data?.original_media_url) {
return [
{
src: mediaData.siteUrl + mediaData.data.original_media_url,
type: 'video/mp4',
},
];
}
// Default sample video
return [
{
src: '/videos/sample-video.mp4',
type: 'video/mp4',
},
],
}),
];
};
return {
id: mediaData.data?.friendly_token || 'default-video',
title: mediaData.data?.title || 'Video',
poster: mediaData.siteUrl + mediaData.data?.poster_url || '',
previewSprite: mediaData?.previewSprite || {},
related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null,
sources: getVideoSources(),
};
},
[mediaData]
);
// Compute available qualities. Prefer JSON (mediaData.data.qualities), otherwise build a full ordered list using the current source.
// Compute available qualities. Prefer JSON (mediaData.data.qualities), otherwise build from encodings_info or current source.
const availableQualities = useMemo(() => {
const desiredOrder = ['auto', '144p', '240p', '360p', '480p', '720p', '1080p'];
// Generate desiredOrder dynamically based on available data
const generateDesiredOrder = () => {
const baseOrder = ['auto'];
// Add qualities from encodings_info if available
if (mediaData.data?.encodings_info) {
const availableQualities = Object.keys(mediaData.data.encodings_info)
.filter(quality => {
const encoding = mediaData.data.encodings_info[quality];
return encoding && encoding.h264 && encoding.h264.url;
})
.map(quality => `${quality}p`)
.sort((a, b) => parseInt(a) - parseInt(b)); // Sort ascending
baseOrder.push(...availableQualities);
} else {
// Fallback to standard order
baseOrder.push('144p', '240p', '360p', '480p', '720p', '1080p', '1440p', '2160p');
}
return baseOrder;
};
const desiredOrder = generateDesiredOrder();
const normalize = (arr) => {
const norm = arr.map((q) => ({
@@ -660,20 +781,17 @@ function VideoJSPlayer() {
src: q.src || q.url || q.href,
type: q.type || 'video/mp4',
}));
// ensure all desired present
const have = new Set(norm.map((q) => q.value));
desiredOrder.forEach((v) => {
if (!have.has(v)) {
norm.push({ label: v === 'auto' ? 'Auto' : v, value: v });
}
});
// sort
// Only include qualities that have actual sources
const validQualities = norm.filter(q => q.src);
// sort based on desired order
const idx = (v) => {
const i = desiredOrder.indexOf(String(v).toLowerCase());
return i === -1 ? 999 : i;
};
norm.sort((a, b) => idx(a.value) - idx(b.value));
return norm;
validQualities.sort((a, b) => idx(a.value) - idx(b.value));
return validQualities;
};
const jsonList = mediaData?.data?.qualities;
@@ -681,17 +799,81 @@ function VideoJSPlayer() {
return normalize(jsonList);
}
// Build from current source
// If HLS is available, build qualities from HLS playlists
if (mediaData.data?.hls_info && mediaData.data.hls_info.master_file) {
const hlsInfo = mediaData.data.hls_info;
const qualities = [];
// Add master file as auto quality
qualities.push({
label: 'Auto',
value: 'auto',
src: mediaData.siteUrl + hlsInfo.master_file,
type: 'application/x-mpegURL',
});
// Add individual HLS playlists
Object.keys(hlsInfo).forEach(key => {
if (key.endsWith('_playlist')) {
const quality = key.replace('_playlist', '');
qualities.push({
label: `${quality}p`,
value: `${quality}p`,
src: mediaData.siteUrl + hlsInfo[key],
type: 'application/x-mpegURL',
});
}
});
return normalize(qualities);
}
// Build from encodings_info if available
if (mediaData.data?.encodings_info) {
const encodings = mediaData.data.encodings_info;
const qualities = [];
// Add auto quality first
qualities.push({
label: 'Auto',
value: 'auto',
src: null, // Will use the highest available quality
type: 'video/mp4',
});
// Add available encoded qualities dynamically
Object.keys(encodings).forEach(quality => {
if (encodings[quality] && encodings[quality].h264 && encodings[quality].h264.url) {
qualities.push({
label: `${quality}p`,
value: `${quality}p`,
src: mediaData.siteUrl + encodings[quality].h264.url,
type: 'video/mp4',
});
}
});
if (qualities.length > 1) { // More than just auto
return normalize(qualities);
}
}
// Build from current source as fallback - only if we have a valid source
const baseSrc = (currentVideo?.sources && currentVideo.sources[0]?.src) || null;
const type = (currentVideo?.sources && currentVideo.sources[0]?.type) || 'video/mp4';
const buildFromBase = desiredOrder.map((v) => ({
label: v === 'auto' ? 'Auto' : v,
value: v,
src: baseSrc || undefined,
type,
}));
return normalize(buildFromBase);
if (baseSrc) {
const buildFromBase = [{
label: 'Auto',
value: 'auto',
src: baseSrc,
type,
}];
return normalize(buildFromBase);
}
// Return empty array if no valid sources found
return [];
}, [mediaData, currentVideo]);
// Get related videos from mediaData instead of static data
@@ -719,7 +901,7 @@ function VideoJSPlayer() {
}));
}, [mediaData]);
// Demo array for testing purposes
// Demo array for testing purposes
const demoSubtitleTracks = [
{
kind: 'subtitles',
@@ -736,12 +918,12 @@ function VideoJSPlayer() {
default: false,
},
];
// const demoSubtitleTracks = []; // NO Subtitles. TODO: hide it on production
// Get subtitle tracks from backend response or fallback based on environment
const backendSubtitles = mediaData?.data?.subtitles_info || [];
const isDevelopment = process.env.NODE_ENV === 'development' || window.location.hostname === 'localhost';
const hasSubtitles = backendSubtitles.length > 0 || isDevelopment;
const backendSubtitles = mediaData?.data?.subtitles_info || (isDevelopment ? demoSubtitleTracks : []);
const hasSubtitles = backendSubtitles.length > 0;
const subtitleTracks = hasSubtitles
? backendSubtitles.map(track => ({
kind: 'subtitles',
@@ -750,7 +932,11 @@ function VideoJSPlayer() {
label: track.label,
default: false,
}))
: (isDevelopment ? demoSubtitleTracks : []);
: [];
console.log('mediaData?.data?.thumbnail_time', mediaData?.data?.thumbnail_time);
console.log('mediaData?.data?.sprites_url', mediaData?.data?.sprites_url);
console.log('mediaData', mediaData);
// Function to navigate to next video
const goToNextVideo = () => {
@@ -1078,8 +1264,6 @@ function VideoJSPlayer() {
}
// BEGIN: Add subtitle tracks
hasSubtitles && subtitleTracks.forEach((track) => {
playerRef.current.addRemoteTextTrack(track, false);
});
@@ -1426,6 +1610,7 @@ function VideoJSPlayer() {
customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, {
userPreferences: userPreferences.current,
qualities: availableQualities,
hasSubtitles: hasSubtitles,
});
// If qualities change per video (e.g., via MEDIA_DATA update), refresh menu
-5
View File
@@ -12,12 +12,7 @@ const mountComponents = () => {
const root = createRoot(rootContainer);
root.render(
<StrictMode>
<div className='video-wrapper'>
<div className='video-box'>
<VideoJS />
</div>
{/* <ChapterList /> */}
</div>
</StrictMode>
);
}