Compare commits

...

10 Commits

Author SHA1 Message Date
Yiannis Christodoulou
4e5b5a3e5b build assets 2025-10-19 13:20:20 +03:00
Yiannis Christodoulou
192333b8d9 Refine sprite frame calculation and cleanup logging
Improves frame index calculation for sprite previews in ChapterMarkers and SpritePreview components by removing arbitrary frame caps and fallback logic, ensuring accurate frame selection based on video duration and interval. Also removes unnecessary console logging from SpritePreview.
2025-10-19 13:18:55 +03:00
Yiannis Christodoulou
a61692aa8c build assets 2025-10-19 12:43:51 +03:00
Yiannis Christodoulou
08ba5ff74c Fix chapter validation in video_chapters view
Update the check for 'chapters' in the request body to distinguish between missing and empty arrays, ensuring proper error handling when the 'chapters' key is absent.
2025-10-19 12:42:39 +03:00
Yiannis Christodoulou
03872d0b25 Support empty chapters state in editor
Allows users to clear all chapters, sending an empty array to the backend. Removes default segment creation when no chapters exist, updates UI and modal messaging for empty state, and ensures backend receives empty chapters when appropriate.
2025-10-19 12:40:12 +03:00
Yiannis Christodoulou
c071524cb9 Support audio in trim video URL and path properties
Expanded the media type check in trim_video_url and trim_video_path properties to include 'audio' alongside 'video', allowing these properties to handle audio media types as well.
2025-10-19 12:10:03 +03:00
Yiannis Christodoulou
c21317dbb4 Close settings menu after option selection
Update speed, quality, and subtitle change handlers to close the entire settings menu after a selection is made, instead of only closing submenus or overlays. This improves user experience by streamlining menu interactions.
2025-10-19 11:12:08 +03:00
Yiannis Christodoulou
1887c262d5 Improve subtitle restoration for mobile devices
Increases subtitle restoration attempts and uses exponential backoff to better support mobile devices where text tracks may load slowly. Adds logic to only warn when subtitle tracks are present but the desired language is not found, and improves error handling for native text track access.
2025-10-18 22:00:20 +03:00
Yiannis Christodoulou
b2729d16aa Hide settings tooltip when menu is open
Removes the tooltip from the settings button when the settings menu is open and restores it when the menu is closed. This improves user experience by preventing the tooltip from overlapping the open menu.
2025-10-18 21:43:24 +03:00
Yiannis Christodoulou
1a080adddc cleanup comments and old code 2025-10-18 21:34:19 +03:00
16 changed files with 295 additions and 2215 deletions

View File

@ -630,7 +630,7 @@ class Media(models.Model):
@property @property
def trim_video_url(self): def trim_video_url(self):
if self.media_type not in ["video"]: if self.media_type not in ["video", "audio"]:
return None return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
@ -642,7 +642,7 @@ class Media(models.Model):
@property @property
def trim_video_path(self): def trim_video_path(self):
if self.media_type not in ["video"]: if self.media_type not in ["video", "audio"]:
return None return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()

View File

@ -258,7 +258,7 @@ def video_chapters(request, friendly_token):
try: try:
request_data = json.loads(request.body) request_data = json.loads(request.body)
data = request_data.get("chapters") data = request_data.get("chapters")
if not data: if data is None:
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400) return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
chapters = [] chapters = []

View File

@ -508,18 +508,17 @@ const TimelineControls = ({
to: formatDetailedTime(segment.endTime), to: formatDetailedTime(segment.endTime),
})); }));
if (chapters.length === 0) { // Allow saving even when no chapters exist (will send empty array)
setErrorMessage('No chapters with titles found');
setShowErrorModal(true);
setShowProcessingModal(false);
return;
}
// Call the onChapterSave function if provided // Call the onChapterSave function if provided
if (onChapterSave) { if (onChapterSave) {
await onChapterSave(chapters); await onChapterSave(chapters);
setShowProcessingModal(false); setShowProcessingModal(false);
setSuccessMessage('Chapters saved successfully!');
if (chapters.length === 0) {
setSuccessMessage('All chapters cleared successfully!');
} else {
setSuccessMessage('Chapters saved successfully!');
}
// Set redirect URL to media page // Set redirect URL to media page
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
@ -1975,48 +1974,12 @@ const TimelineControls = ({
// Check if this was the last segment before deletion // Check if this was the last segment before deletion
const remainingSegments = clipSegments.filter((seg) => seg.id !== segmentId); const remainingSegments = clipSegments.filter((seg) => seg.id !== segmentId);
if (remainingSegments.length === 0) { if (remainingSegments.length === 0) {
// Create a full video segment // Allow empty state - clear all UI state
const fullVideoSegment: Segment = { setSelectedSegmentId(null);
id: Date.now(),
chapterTitle: 'Full Video',
startTime: 0,
endTime: duration,
};
// Create and dispatch the update event to replace all segments with the full video segment
const updateEvent = new CustomEvent('update-segments', {
detail: {
segments: [fullVideoSegment],
recordHistory: true,
action: 'create_full_video_segment',
},
});
document.dispatchEvent(updateEvent);
// Update UI to show the segment tooltip
setSelectedSegmentId(fullVideoSegment.id);
setShowEmptySpaceTooltip(false); setShowEmptySpaceTooltip(false);
setClickedTime(currentTime); setActiveSegment(null);
setDisplayTime(currentTime);
setActiveSegment(fullVideoSegment);
// Calculate tooltip position at current time logger.debug('All segments deleted - entering empty state');
if (timelineRef.current) {
const rect = timelineRef.current.getBoundingClientRect();
const posPercent = (currentTime / duration) * 100;
const xPosition = rect.left + rect.width * (posPercent / 100);
setTooltipPosition({
x: xPosition,
y: rect.top - 10,
});
logger.debug('Created full video segment:', {
id: fullVideoSegment.id,
duration: formatDetailedTime(duration),
currentPosition: formatDetailedTime(currentTime),
});
}
} else if (selectedSegmentId === segmentId) { } else if (selectedSegmentId === segmentId) {
// Handle normal segment deletion // Handle normal segment deletion
const deletedSegment = clipSegments.find((seg) => seg.id === segmentId); const deletedSegment = clipSegments.find((seg) => seg.id === segmentId);
@ -3984,10 +3947,13 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
data-tooltip="Save chapters" data-tooltip={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0} ? "Clear all chapters"
: "Save chapters"}
> >
Save Chapters {clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
? 'Clear Chapters'
: 'Save Chapters'}
</button> </button>
</div> </div>
@ -4008,15 +3974,22 @@ const TimelineControls = ({
className="modal-button modal-button-primary" className="modal-button modal-button-primary"
onClick={handleSaveChaptersConfirm} onClick={handleSaveChaptersConfirm}
> >
Save Chapters {clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
? 'Clear Chapters'
: 'Save Chapters'}
</button> </button>
</> </>
} }
> >
<p className="modal-message"> <p className="modal-message">
Are you sure you want to save the chapters? This will save{' '} {(() => {
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the const chaptersWithTitles = clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length;
database. if (chaptersWithTitles === 0) {
return "Are you sure you want to clear all chapters? This will remove all existing chapters from the database.";
} else {
return `Are you sure you want to save the chapters? This will save ${chaptersWithTitles} chapters to the database.`;
}
})()}
</p> </p>
</Modal> </Modal>

View File

@ -147,15 +147,8 @@ const useVideoChapters = () => {
initialSegments.push(segment); initialSegments.push(segment);
} }
} else { } else {
// Start with empty state - no default segment
const initialSegment: Segment = { initialSegments = [];
id: 1,
chapterTitle: '',
startTime: 0,
endTime: video.duration,
};
initialSegments = [initialSegment];
} }
// Initialize history state with the segments // Initialize history state with the segments
@ -274,24 +267,17 @@ const useVideoChapters = () => {
// Check if we now have duration and initialize if needed // Check if we now have duration and initialize if needed
if (video.duration > 0 && clipSegments.length === 0) { if (video.duration > 0 && clipSegments.length === 0) {
logger.debug('Safari: Successfully initialized metadata, creating default segment'); logger.debug('Safari: Successfully initialized metadata with empty state');
const defaultSegment: Segment = {
id: 1,
chapterTitle: '',
startTime: 0,
endTime: video.duration,
};
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
setClipSegments([defaultSegment]); setClipSegments([]);
const initialState: EditorState = { const initialState: EditorState = {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [defaultSegment], clipSegments: [],
}; };
setHistory([initialState]); setHistory([initialState]);
@ -680,21 +666,13 @@ const useVideoChapters = () => {
const newSegments = clipSegments.filter((segment) => segment.id !== segmentId); const newSegments = clipSegments.filter((segment) => segment.id !== segmentId);
if (newSegments.length !== clipSegments.length) { if (newSegments.length !== clipSegments.length) {
// If all segments are deleted, create a new full video segment if (newSegments.length === 0) {
if (newSegments.length === 0 && videoRef.current) { // Allow empty state - no segments
// Create a new default segment that spans the entire video setClipSegments([]);
const defaultSegment: Segment = {
id: Date.now(),
chapterTitle: 'Chapter 1',
startTime: 0,
endTime: videoRef.current.duration,
};
// Reset the trim points as well // Reset the trim points as well
setTrimStart(0); setTrimStart(0);
setTrimEnd(videoRef.current.duration); setTrimEnd(videoRef.current?.duration || 0);
setSplitPoints([]); setSplitPoints([]);
setClipSegments([defaultSegment]);
} else { } else {
// Renumber remaining segments to ensure proper chronological naming // Renumber remaining segments to ensure proper chronological naming
const renumberedSegments = renumberAllSegments(newSegments); const renumberedSegments = renumberAllSegments(newSegments);
@ -767,17 +745,8 @@ const useVideoChapters = () => {
setTrimEnd(duration); setTrimEnd(duration);
setSplitPoints([]); setSplitPoints([]);
// Create a new default segment that spans the entire video // Reset to empty state - no default segment
if (!videoRef.current) return; setClipSegments([]);
const defaultSegment: Segment = {
id: Date.now(),
chapterTitle: 'Chapter 1',
startTime: 0,
endTime: duration,
};
setClipSegments([defaultSegment]);
saveState('reset_all'); saveState('reset_all');
}; };
@ -918,7 +887,7 @@ const useVideoChapters = () => {
} }
// Convert chapters to backend expected format and sort by start time // Convert chapters to backend expected format and sort by start time
const backendChapters = chapters let backendChapters = chapters
.map((chapter) => ({ .map((chapter) => ({
startTime: chapter.from, startTime: chapter.from,
endTime: chapter.to, endTime: chapter.to,
@ -931,6 +900,21 @@ const useVideoChapters = () => {
return aStartSeconds - bStartSeconds; return aStartSeconds - bStartSeconds;
}); });
// If there's only one chapter that spans the full video duration, send empty array
if (backendChapters.length === 1) {
const singleChapter = backendChapters[0];
const startSeconds = parseTimeToSeconds(singleChapter.startTime);
const endSeconds = parseTimeToSeconds(singleChapter.endTime);
// Check if this single chapter spans the entire video (within 0.1 second tolerance)
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
if (isFullVideoChapter) {
logger.debug('Manual save: Single chapter spans full video - sending empty array');
backendChapters = [];
}
}
// Create the API request body // Create the API request body
const requestData = { const requestData = {
chapters: backendChapters, chapters: backendChapters,

View File

@ -990,8 +990,12 @@ class CustomSettingsMenu extends Component {
if (btnEl) { if (btnEl) {
if (!isVisible) { if (!isVisible) {
btnEl.classList.add('settings-clicked'); btnEl.classList.add('settings-clicked');
// Hide tooltip when menu is open
btnEl.removeAttribute('title');
} else { } else {
btnEl.classList.remove('settings-clicked'); btnEl.classList.remove('settings-clicked');
// Restore tooltip when menu is closed
btnEl.setAttribute('title', 'Settings');
} }
} }
} }
@ -1011,10 +1015,12 @@ class CustomSettingsMenu extends Component {
this.refreshSubtitlesSubmenu(); this.refreshSubtitlesSubmenu();
} }
// Mark settings button as active // Mark settings button as active and hide tooltip
const btnEl = this.settingsButton?.el(); const btnEl = this.settingsButton?.el();
if (btnEl) { if (btnEl) {
btnEl.classList.add('settings-clicked'); btnEl.classList.add('settings-clicked');
// Hide tooltip when menu is open
btnEl.removeAttribute('title');
} }
} }
@ -1032,10 +1038,12 @@ class CustomSettingsMenu extends Component {
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none'; if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none'; if (this.subtitlesSubmenu) this.subtitlesSubmenu.style.display = 'none';
// Remove active state from settings button // Remove active state from settings button and restore tooltip
const btnEl = this.settingsButton?.el(); const btnEl = this.settingsButton?.el();
if (btnEl) { if (btnEl) {
btnEl.classList.remove('settings-clicked'); btnEl.classList.remove('settings-clicked');
// Restore tooltip when menu is closed
btnEl.setAttribute('title', 'Settings');
} }
// Restore body scroll on mobile when closing // Restore body scroll on mobile when closing
@ -1071,8 +1079,8 @@ class CustomSettingsMenu extends Component {
const speedLabel = speed === 1 ? 'Normal' : `${speed}`; const speedLabel = speed === 1 ? 'Normal' : `${speed}`;
currentSpeedDisplay.textContent = speedLabel; currentSpeedDisplay.textContent = speedLabel;
// Close only the speed submenu (keep overlay open) // Close the entire settings menu after speed selection
this.speedSubmenu.style.display = 'none'; this.closeMenu();
} }
handleQualityChange(value, qualityOption) { handleQualityChange(value, qualityOption) {
@ -1249,6 +1257,9 @@ class CustomSettingsMenu extends Component {
} catch (e) {} } catch (e) {}
player.removeClass('vjs-changing-resolution'); player.removeClass('vjs-changing-resolution');
// Close the entire settings menu after quality change completes
this.closeMenu();
}; };
// Wait for metadata/data to be ready, then restore // Wait for metadata/data to be ready, then restore
@ -1258,10 +1269,10 @@ class CustomSettingsMenu extends Component {
player.one('loadeddata', finishRestore); player.one('loadeddata', finishRestore);
}; };
player.one('loadedmetadata', onLoadedMeta); player.one('loadedmetadata', onLoadedMeta);
} else {
// If no source switch needed, close menu immediately
this.closeMenu();
} }
// Close only the quality submenu (keep overlay open)
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
} }
handleSubtitleChange(lang, optionEl) { handleSubtitleChange(lang, optionEl) {
@ -1302,16 +1313,8 @@ class CustomSettingsMenu extends Component {
const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles'); const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles');
if (currentSubtitlesDisplay) currentSubtitlesDisplay.textContent = label; if (currentSubtitlesDisplay) currentSubtitlesDisplay.textContent = label;
// Close the entire settings overlay after subtitle selection // Close the entire settings menu after subtitle selection
this.settingsOverlay.classList.remove('show'); this.closeMenu();
this.settingsOverlay.style.display = 'none';
this.subtitlesSubmenu.style.display = 'none';
// Remove active state from settings button
const btnEl = this.settingsButton?.el();
if (btnEl) {
btnEl.classList.remove('settings-clicked');
}
} }
restoreSubtitlePreference() { restoreSubtitlePreference() {

View File

@ -310,20 +310,20 @@ class ChapterMarkers extends Component {
// Use sprite interval from frame data, fallback to 10 seconds // Use sprite interval from frame data, fallback to 10 seconds
const frameInterval = frame.seconds || 10; const frameInterval = frame.seconds || 10;
// Try to detect total frames based on video duration vs frame interval // Calculate total frames based on video duration vs frame interval
const videoDuration = this.player().duration() || 45; // fallback duration const videoDuration = this.player().duration();
const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval); if (!videoDuration) return;
const maxFrames = Math.min(calculatedMaxFrames, 6); // Cap at 6 frames to be safe
const maxFrames = Math.ceil(videoDuration / frameInterval);
let frameIndex = Math.floor(currentTime / frameInterval); let frameIndex = Math.floor(currentTime / frameInterval);
// Clamp frameIndex to available frames to prevent showing empty areas // Clamp frameIndex to available frames to prevent showing empty areas
frameIndex = Math.min(frameIndex, maxFrames - 1); frameIndex = Math.min(frameIndex, maxFrames - 1);
frameIndex = Math.max(frameIndex, 0);
// Based on the sprite image you shared, it appears to have frames arranged vertically // Frames are arranged vertically (1 column, multiple rows)
// Let's try a vertical layout first (1 column, multiple rows) const frameRow = frameIndex;
const frameRow = frameIndex; // Each frame is on its own row const frameCol = 0;
const frameCol = 0; // Always first (and only) column
// Calculate background position (negative values to shift the sprite) // Calculate background position (negative values to shift the sprite)
const xPos = -(frameCol * width); const xPos = -(frameCol * width);
@ -337,12 +337,6 @@ class ChapterMarkers extends Component {
// Ensure the image is visible // Ensure the image is visible
this.chapterImage.style.display = 'block'; this.chapterImage.style.display = 'block';
// Fallback: if we're beyond frame 3 (30s+), try showing frame 2 instead (20-30s frame)
if (frameIndex >= 3 && currentTime > 30) {
const fallbackYPos = -(2 * height); // Frame 2 (20-30s range)
this.chapterImage.style.backgroundPosition = `${xPos}px ${fallbackYPos}px`;
}
} }
formatTime(seconds) { formatTime(seconds) {

View File

@ -38,18 +38,15 @@ class SpritePreview extends Component {
// Try to get progress control from control bar first, then from moved location // Try to get progress control from control bar first, then from moved location
let progressControl = this.player().getChild('controlBar').getChild('progressControl'); let progressControl = this.player().getChild('controlBar').getChild('progressControl');
console.log('SpritePreview: progressControl from controlBar:', progressControl);
// If not found in control bar, it might have been moved to a wrapper // If not found in control bar, it might have been moved to a wrapper
if (!progressControl) { if (!progressControl) {
// Look for moved progress control in custom components // Look for moved progress control in custom components
const customComponents = this.player().customComponents || {}; const customComponents = this.player().customComponents || {};
progressControl = customComponents.movedProgressControl; progressControl = customComponents.movedProgressControl;
console.log('SpritePreview: progressControl from customComponents:', progressControl);
} }
if (!progressControl) { if (!progressControl) {
console.log('SpritePreview: No progress control found!');
return; return;
} }
@ -197,20 +194,20 @@ class SpritePreview extends Component {
// Use sprite interval from frame data, fallback to 10 seconds // Use sprite interval from frame data, fallback to 10 seconds
const frameInterval = frame.seconds || 10; const frameInterval = frame.seconds || 10;
// Try to detect total frames based on video duration vs frame interval // Calculate total frames based on video duration vs frame interval
const videoDuration = this.player().duration() || 45; // fallback duration const videoDuration = this.player().duration();
const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval); if (!videoDuration) return;
const maxFrames = Math.min(calculatedMaxFrames, 6); // Cap at 6 frames to be safe
const maxFrames = Math.ceil(videoDuration / frameInterval);
let frameIndex = Math.floor(currentTime / frameInterval); let frameIndex = Math.floor(currentTime / frameInterval);
// Clamp frameIndex to available frames to prevent showing empty areas // Clamp frameIndex to available frames to prevent showing empty areas
frameIndex = Math.min(frameIndex, maxFrames - 1); frameIndex = Math.min(frameIndex, maxFrames - 1);
frameIndex = Math.max(frameIndex, 0);
// Based on the sprite image, it appears to have frames arranged vertically // Frames are arranged vertically (1 column, multiple rows)
// Let's try a vertical layout first (1 column, multiple rows) const frameRow = frameIndex;
const frameRow = frameIndex; // Each frame is on its own row const frameCol = 0;
const frameCol = 0; // Always first (and only) column
// Calculate background position (negative values to shift the sprite) // Calculate background position (negative values to shift the sprite)
const xPos = -(frameCol * width); const xPos = -(frameCol * width);
@ -227,12 +224,6 @@ class SpritePreview extends Component {
// Ensure the image is visible // Ensure the image is visible
this.spriteImage.style.display = 'block'; this.spriteImage.style.display = 'block';
// Fallback: if we're beyond frame 3 (30s+), try showing frame 2 instead (20-30s frame)
if (frameIndex >= 3 && currentTime > 30) {
const fallbackYPos = -(2 * height); // Frame 2 (20-30s range)
this.spriteImage.style.backgroundPosition = `${xPos}px ${fallbackYPos}px`;
}
} }
formatTime(seconds) { formatTime(seconds) {

View File

@ -1,689 +0,0 @@
/* ===== END SCREEN OVERLAY STYLES ===== */
.vjs-end-screen-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: calc(100% - 80px); /* Reduce reserved space for seekbar */
background: #000000;
display: none;
flex-direction: column;
justify-content: center; /* Center the grid vertically */
align-items: center;
padding: 40px 40px 40px 40px; /* Equal visual margins on all sides */
box-sizing: border-box;
z-index: 9999;
overflow: hidden;
}
/* Hide poster image when video ends and end screen is shown */
.video-js.vjs-ended .vjs-poster {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
z-index: -1 !important;
width: 0 !important;
height: 0 !important;
}
/* Hide video element completely when ended */
.video-js.vjs-ended video {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Ensure the overlay covers everything with maximum z-index */
.video-js.vjs-ended .vjs-end-screen-overlay {
z-index: 99999 !important;
display: flex !important;
}
/* Embed-specific full page overlay */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 80px) !important; /* Reduce reserved space for controls */
z-index: 9998 !important; /* Below controls but above video */
display: flex !important;
padding: 120px 40px 40px 40px !important; /* Top padding for embed info + equal visual margins */
justify-content: center !important; /* Center the grid vertically */
}
/* Small player size optimization - 2 items horizontally for better title readability */
/* This applies to both embed and regular players when they're small */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px; /* Limit width for better proportions */
}
.vjs-end-screen-overlay.vjs-small-player {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
/* Hide items beyond the first 2 for small players */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-video-item:nth-child(n + 3) {
display: none !important;
}
/* Embed-specific adjustments for small sizes */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-small-player {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
/* Fallback media query for cases where class detection might not work */
@media (max-height: 500px), (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
.vjs-related-video-item:nth-child(n + 3) {
display: none !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
}
/* Very small player size - further optimize spacing (class-based detection) */
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
/* Fallback media query for very small sizes */
@media (max-height: 400px), (max-width: 400px) {
.vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
}
/* Ensure controls stay visible over the black background */
.video-js.vjs-ended .vjs-control-bar {
z-index: 10000 !important;
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
.video-js.vjs-ended .vjs-progress-control {
z-index: 10001 !important;
position: absolute !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Embed-specific controls handling when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10000 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-progress-control {
position: fixed !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10001 !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Ensure embed info overlay (title/avatar) stays visible when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-embed-info-overlay {
z-index: 10002 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Hide big play button when end screen is active */
.video-js.vjs-ended .vjs-big-play-button {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Hide seek indicator (play icon) when end screen is active */
.video-js.vjs-ended .vjs-seek-indicator {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Make control bar and seekbar background black when video ends */
.video-js.vjs-ended .vjs-control-bar {
background: #000000 !important;
background-color: #000000 !important;
background-image: none !important;
}
.video-js.vjs-ended .vjs-progress-control {
background: #000000 !important;
background-color: #000000 !important;
}
/* Also ensure the gradient overlay is black when ended */
.video-js.vjs-ended::after {
background: #000000 !important;
background-image: none !important;
}
/* Remove any white elements or gradients */
.video-js.vjs-ended::before {
background: #000000 !important;
background-image: none !important;
}
/* Ensure all VideoJS overlays are black but preserve seekbar colors */
.video-js.vjs-ended .vjs-loading-spinner,
.video-js.vjs-ended .vjs-mouse-display {
background: #000000 !important;
background-image: none !important;
}
/* Only change the background holder, preserve progress colors */
.video-js.vjs-ended .vjs-progress-holder {
background: rgba(255, 255, 255, 0.3) !important; /* Keep original transparent background */
}
/* Hide any remaining VideoJS elements that might show white */
.video-js.vjs-ended .vjs-tech,
.video-js.vjs-ended .vjs-poster-overlay {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
.vjs-related-videos-title {
color: white;
font-size: 24px;
line-height: 24px;
padding: 0;
margin: 0;
text-align: center;
font-weight: bold;
flex-shrink: 0;
}
.vjs-related-videos-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
width: 100%;
max-width: 100%;
margin: 0; /* Remove margin since parent handles centering */
box-sizing: border-box;
justify-items: stretch;
align-items: stretch;
justify-content: center;
align-content: center; /* Center grid content */
overflow: hidden;
grid-auto-rows: 1fr;
}
.vjs-related-video-item {
position: relative;
cursor: pointer;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
background: #1a1a1a;
border: 1px solid #333;
aspect-ratio: 16/9;
width: 100%;
min-height: 100px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Apply rounded corners only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-item {
border-radius: 8px;
}
.vjs-related-video-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.vjs-related-video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* border-radius: 8px; */
background: #1a1a1a; /* Fallback background */
transition: transform 0.2s ease;
}
.vjs-related-video-item:hover .vjs-related-video-thumbnail {
transform: scale(1.02); /* Subtle zoom like YouTube */
}
.vjs-related-video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
color: white;
padding: 12px;
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: 100%;
}
.vjs-related-video-item:hover .vjs-related-video-overlay {
opacity: 1;
}
/* Show overlay by default on touch devices - match default hover behavior exactly */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-overlay {
opacity: 1;
}
.vjs-related-video-title {
font-size: 14px;
font-weight: bold;
line-height: 1.3;
color: white;
margin-bottom: 4px;
}
.vjs-related-video-meta {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vjs-related-video-author {
font-size: 12px;
color: #fff;
}
.vjs-related-video-views {
font-size: 12px;
color: #fff;
}
.vjs-related-video-author::after {
content: "•";
margin-left: 8px;
color: #fff;
}
.vjs-related-video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s ease;
}
/* Apply rounded corners to duration badge only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-duration {
border-radius: 2px;
}
.vjs-related-video-item:hover .vjs-related-video-duration {
opacity: 1;
}
/* Show duration by default on touch devices */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-duration {
opacity: 1;
}
.video-js.vjs-ended .vjs-control-bar {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar button {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control.vjs-volume-control {
opacity: 0 !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-volume-panel.vjs-hover .vjs-volume-control {
opacity: 1 !important;
}
/* Responsive grid adjustments for different screen sizes */
@media (max-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 70px);
padding: 35px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 70px) !important;
padding: 115px 35px 35px 35px !important;
}
}
@media (max-width: 900px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
}
@media (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px);
padding: 25px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 105px 25px 25px 25px !important;
}
.vjs-related-video-item {
min-height: 80px;
}
}
@media (max-width: 400px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
height: calc(100% - 40px);
padding: 20px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 40px) !important;
padding: 100px 20px 20px 20px !important;
}
.vjs-related-video-item {
min-height: 70px;
}
}
.video-js.vjs-ended .vjs-play-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-progress-control {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-volume-panel {
opacity: 1 !important;
pointer-events: auto !important;
}
/* Responsive grid layouts */
@media (min-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.vjs-end-screen-overlay {
height: calc(100% - 80px);
padding: 40px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 80px) !important;
padding: 120px 40px 40px 40px !important;
}
}
@media (max-width: 1199px) {
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 1100px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
}
/* iPad Pro and larger tablets */
@media (min-width: 1024px) and (max-width: 1199px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on larger tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
/* Large tablets like iPad Pro */
@media (min-width: 900px) and (max-width: 1024px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on large tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (min-width: 768px) and (max-width: 899px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
/* Allow up to 9 videos on regular tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 767px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
}
.vjs-end-screen-overlay {
padding: 12px;
justify-content: center;
height: calc(100% - 105px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 105px) !important;
padding: 80px 12px 12px 12px !important;
}
.vjs-related-video-thumbnail {
height: 100%;
}
}
@media (max-width: 574px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
padding: 10px;
justify-content: center;
height: calc(100% - 100px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 100px) !important;
padding: 80px 10px 10px 10px !important;
}
}
@media (max-width: 439px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
padding: 8px;
justify-content: center;
height: calc(100% - 98px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 98px) !important;
padding: 80px 8px 8px 8px !important;
}
}
@media (max-width: 480px) {
.vjs-related-video-thumbnail {
height: 100%;
}
}

View File

@ -1,377 +0,0 @@
import videojs from 'video.js';
import './EndScreenOverlay.css';
const Component = videojs.getComponent('Component');
class EndScreenOverlay extends Component {
constructor(player, options) {
// Store relatedVideos in options before calling super
// so it's available during createEl()
if (options && options.relatedVideos) {
options._relatedVideos = options.relatedVideos;
}
super(player, options);
// Now set the instance property after super() completes
this.relatedVideos = options && options.relatedVideos ? options.relatedVideos : [];
}
createEl() {
// Get relatedVideos from options since createEl is called during super()
const relatedVideos = this.options_ && this.options_._relatedVideos ? this.options_._relatedVideos : [];
// Limit videos based on screen size to fit grid properly
const maxVideos = this.getMaxVideosForScreen();
const videosToShow = relatedVideos.slice(0, maxVideos);
// Determine if player is small and add appropriate class
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
const isSmallPlayer = playerHeight <= 500 || playerWidth <= 600;
const isVerySmallPlayer = playerHeight <= 400 || playerWidth <= 400;
let overlayClasses = 'vjs-end-screen-overlay';
if (isVerySmallPlayer) {
overlayClasses += ' vjs-very-small-player vjs-small-player';
} else if (isSmallPlayer) {
overlayClasses += ' vjs-small-player';
}
const overlay = super.createEl('div', {
className: overlayClasses,
});
// Create grid container
const grid = videojs.dom.createEl('div', {
className: 'vjs-related-videos-grid',
});
// Create video items
if (videosToShow && Array.isArray(videosToShow) && videosToShow.length > 0) {
videosToShow.forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
} else {
// Create sample videos for testing if no related videos provided
const sampleVideos = this.createSampleVideos();
sampleVideos.slice(0, this.getMaxVideosForScreen()).forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
}
overlay.appendChild(grid);
return overlay;
}
createVideoItem(video) {
// Detect touch device
const isTouchDevice = this.isTouchDevice();
const item = videojs.dom.createEl('div', {
className: isTouchDevice ? 'vjs-related-video-item vjs-touch-device' : 'vjs-related-video-item',
});
// Use real YouTube thumbnail or fallback to placeholder
const thumbnailSrc = video.thumbnail || this.getPlaceholderImage(video.title);
const thumbnail = videojs.dom.createEl('img', {
className: 'vjs-related-video-thumbnail',
src: thumbnailSrc,
alt: video.title,
loading: 'lazy', // Lazy load for better performance
onerror: () => {
// Fallback to placeholder if image fails to load
thumbnail.src = this.getPlaceholderImage(video.title);
},
});
const overlay = videojs.dom.createEl('div', {
className: 'vjs-related-video-overlay',
});
const title = videojs.dom.createEl('div', {
className: 'vjs-related-video-title',
});
title.textContent = video.title;
// Create meta container for author and views
const meta = videojs.dom.createEl('div', {
className: 'vjs-related-video-meta',
});
const author = videojs.dom.createEl('div', {
className: 'vjs-related-video-author',
});
author.textContent = video.author;
const views = videojs.dom.createEl('div', {
className: 'vjs-related-video-views',
});
views.textContent = video.views;
// Add author and views to meta container
meta.appendChild(author);
meta.appendChild(views);
// Add duration display (positioned absolutely in bottom right)
const duration = videojs.dom.createEl('div', {
className: 'vjs-related-video-duration',
});
// Format duration from seconds to MM:SS
const formatDuration = (seconds) => {
if (!seconds || seconds === 0) return '';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
duration.textContent = formatDuration(video.duration);
// Structure: title at top, meta at bottom
overlay.appendChild(title);
overlay.appendChild(meta);
item.appendChild(thumbnail);
item.appendChild(overlay);
// Add duration to the item (positioned absolutely)
if (video.duration && video.duration > 0) {
item.appendChild(duration);
}
// Add click handler
item.addEventListener('click', () => {
// Check if this is an embed player - use multiple methods for reliability
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
window.location.pathname.includes('/embed') ||
window.location.search.includes('embed') ||
window.parent !== window; // Most reliable check for iframe
if (isEmbedPlayer) {
// Open in new tab/window for embed players
window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer');
} else {
// Navigate in same window for regular players
window.location.href = `/view?m=${video.id}`;
}
});
return item;
}
getPlaceholderImage(title) {
// Generate a placeholder image using a service or create a data URL
// For now, we'll use a simple colored placeholder based on the title
const colors = [
'#009931',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9',
];
// Use title hash to consistently assign colors
let hash = 0;
for (let i = 0; i < title.length; i++) {
hash = title.charCodeAt(i) + ((hash << 5) - hash);
}
const colorIndex = Math.abs(hash) % colors.length;
const color = colors[colorIndex];
// Create a simple placeholder with the first letter of the title
const firstLetter = title.charAt(0).toUpperCase();
// Create a data URL for a simple placeholder image
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = color;
ctx.fillRect(0, 0, 320, 180);
// Add a subtle pattern
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
for (let i = 0; i < 20; i++) {
ctx.fillRect(Math.random() * 320, Math.random() * 180, 2, 2);
}
// Add the first letter
ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(firstLetter, 160, 90);
return canvas.toDataURL();
}
getMaxVideosForScreen() {
// Get actual player dimensions instead of window dimensions
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
// Check if this is an embed player
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
document.getElementById('page-embed') ||
window.location.pathname.includes('embed');
// For small player sizes, limit to 2 items for better readability
// This works for both embed and regular players when they're small
if (playerHeight <= 500 || playerWidth <= 600) {
return 2; // 2x1 grid for small player sizes
}
// Use player width for responsive decisions
if (playerWidth >= 1200) {
return 12; // 4x3 grid for large player
} else if (playerWidth >= 1024) {
return 9; // 3x3 grid for desktop-sized player
} else if (playerWidth >= 768) {
return 6; // 3x2 grid for tablet-sized player
} else {
return 4; // 2x2 grid for mobile-sized player
}
}
createSampleVideos() {
return [
{
id: 'sample1',
title: 'React Full Course for Beginners',
author: 'Bro Code',
views: '2.1M views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/dGcsHMXbSOA/maxresdefault.jpg',
},
{
id: 'sample2',
title: 'JavaScript ES6+ Features',
author: 'Tech Tutorials',
views: '850K views',
duration: 1200,
thumbnail: 'https://img.youtube.com/vi/WZQc7RUAg18/maxresdefault.jpg',
},
{
id: 'sample3',
title: 'CSS Grid Layout Masterclass',
author: 'Web Dev Academy',
views: '1.2M views',
duration: 2400,
thumbnail: 'https://img.youtube.com/vi/0xMQfnTU6oo/maxresdefault.jpg',
},
{
id: 'sample4',
title: 'Node.js Backend Development',
author: 'Code Master',
views: '650K views',
duration: 3600,
thumbnail: 'https://img.youtube.com/vi/fBNz6F-Cowg/maxresdefault.jpg',
},
{
id: 'sample5',
title: 'Vue.js Complete Guide',
author: 'Frontend Pro',
views: '980K views',
duration: 2800,
thumbnail: 'https://img.youtube.com/vi/qZXt1Aom3Cs/maxresdefault.jpg',
},
{
id: 'sample6',
title: 'Python Data Science',
author: 'Data Academy',
views: '1.5M views',
duration: 4200,
thumbnail: 'https://img.youtube.com/vi/ua-CiDNNj30/maxresdefault.jpg',
},
{
id: 'sample7',
title: 'TypeScript Fundamentals',
author: 'TypeScript Expert',
views: '720K views',
duration: 2100,
thumbnail: 'https://img.youtube.com/vi/BwuLxPH8IDs/maxresdefault.jpg',
},
{
id: 'sample8',
title: 'MongoDB Database Tutorial',
author: 'Database Pro',
views: '890K views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/-56x56UppqQ/maxresdefault.jpg',
},
{
id: 'sample9',
title: 'Docker Containerization',
author: 'DevOps Master',
views: '1.1M views',
duration: 3200,
thumbnail: 'https://img.youtube.com/vi/pTFZFxd4hOI/maxresdefault.jpg',
},
{
id: 'sample10',
title: 'AWS Cloud Services',
author: 'Cloud Expert',
views: '1.3M views',
duration: 4500,
thumbnail: 'https://img.youtube.com/vi/ITcXLS3h2qU/maxresdefault.jpg',
},
{
id: 'sample11',
title: 'GraphQL API Design',
author: 'API Specialist',
views: '680K views',
duration: 2600,
thumbnail: 'https://img.youtube.com/vi/ed8SzALpx1Q/maxresdefault.jpg',
},
{
id: 'sample12',
title: 'Machine Learning Basics',
author: 'AI Academy',
views: '2.3M views',
duration: 5400,
thumbnail: 'https://img.youtube.com/vi/i_LwzRVP7bg/maxresdefault.jpg',
},
];
}
isTouchDevice() {
// Multiple methods to detect touch devices
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 ||
window.matchMedia('(pointer: coarse)').matches
);
}
show() {
this.el().style.display = 'flex';
}
hide() {
this.el().style.display = 'none';
}
}
// Register the component
videojs.registerComponent('EndScreenOverlay', EndScreenOverlay);
export default EndScreenOverlay;

View File

@ -340,9 +340,28 @@ class UserPreferences {
// Set flag to prevent auto-save during restoration // Set flag to prevent auto-save during restoration
this.isRestoringSubtitles = true; this.isRestoringSubtitles = true;
// Multiple attempts with increasing delays to ensure text tracks are loaded // Multiple attempts with increasing delays to ensure text tracks are loaded
// Mobile devices need more time and attempts
const maxAttempts = 10; // Increased from 5 for mobile compatibility
const attemptToApplySubtitles = (attempt = 1) => { const attemptToApplySubtitles = (attempt = 1) => {
const textTracks = player.textTracks(); const textTracks = player.textTracks();
// Check if we have any subtitle tracks loaded yet
let hasSubtitleTracks = false;
for (let i = 0; i < textTracks.length; i++) {
if (textTracks[i].kind === 'subtitles') {
hasSubtitleTracks = true;
break;
}
}
// If no subtitle tracks found yet and we have attempts left, retry with longer delay
if (!hasSubtitleTracks && attempt < maxAttempts) {
// Use exponential backoff: 100ms, 200ms, 400ms, 800ms, etc.
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
return;
}
// First, disable all subtitle tracks // First, disable all subtitle tracks
for (let i = 0; i < textTracks.length; i++) { for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i]; const track = textTracks[i];
@ -401,13 +420,17 @@ class UserPreferences {
// Clear the restoration flag after a longer delay to ensure all events have settled // Clear the restoration flag after a longer delay to ensure all events have settled
setTimeout(() => { setTimeout(() => {
this.isRestoringSubtitles = false; this.isRestoringSubtitles = false;
}, 600); // Increased to 3 seconds }, 600);
// If not found and we haven't tried too many times, try again // If not found and we haven't tried too many times, try again with longer delay
if (!found && attempt < 5) { if (!found && attempt < maxAttempts) {
setTimeout(() => attemptToApplySubtitles(attempt + 1), attempt * 50); const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
} else if (!found) { } else if (!found) {
console.warn('Could not find subtitle track for language:', savedLanguage); // Only log warning if we had subtitle tracks but couldn't match the language
if (hasSubtitleTracks) {
console.warn('Could not find subtitle track for language:', savedLanguage);
}
// Clear flag even if not found // Clear flag even if not found
this.isRestoringSubtitles = false; this.isRestoringSubtitles = false;
} }
@ -428,7 +451,9 @@ class UserPreferences {
ttList.addEventListener('addtrack', onAddTrack, { once: true }); ttList.addEventListener('addtrack', onAddTrack, { once: true });
ttList.addEventListener('change', onChange, { once: true }); ttList.addEventListener('change', onChange, { once: true });
} }
} catch (e) {} } catch {
// Silently ignore errors accessing native text track list
}
} else { } else {
// Ensure subtitles are off on load when not enabled // Ensure subtitles are off on load when not enabled
try { try {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long