Compare commits

..

No commits in common. "4e5b5a3e5b024ccfffb73e98de3b2684ffa601b7" and "ca0dc294885e54de1e8925a2fbdfa0d68dd7dc2a" have entirely different histories.

16 changed files with 2215 additions and 295 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", "audio"]: if self.media_type not in ["video"]:
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", "audio"]: if self.media_type not in ["video"]:
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 data is None: if not data:
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,17 +508,18 @@ const TimelineControls = ({
to: formatDetailedTime(segment.endTime), to: formatDetailedTime(segment.endTime),
})); }));
// Allow saving even when no chapters exist (will send empty array) if (chapters.length === 0) {
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);
if (chapters.length === 0) {
setSuccessMessage('All chapters cleared successfully!');
} else {
setSuccessMessage('Chapters saved successfully!'); 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;
@ -1974,12 +1975,48 @@ 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) {
// Allow empty state - clear all UI state // Create a full video segment
setSelectedSegmentId(null); const fullVideoSegment: Segment = {
setShowEmptySpaceTooltip(false); id: Date.now(),
setActiveSegment(null); chapterTitle: 'Full Video',
startTime: 0,
endTime: duration,
};
logger.debug('All segments deleted - entering empty state'); // 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);
setClickedTime(currentTime);
setDisplayTime(currentTime);
setActiveSegment(fullVideoSegment);
// Calculate tooltip position at current time
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);
@ -3947,13 +3984,10 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
data-tooltip={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0 data-tooltip="Save chapters"
? "Clear all chapters" disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0}
: "Save chapters"}
> >
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0 Save Chapters
? 'Clear Chapters'
: 'Save Chapters'}
</button> </button>
</div> </div>
@ -3974,22 +4008,15 @@ const TimelineControls = ({
className="modal-button modal-button-primary" className="modal-button modal-button-primary"
onClick={handleSaveChaptersConfirm} onClick={handleSaveChaptersConfirm}
> >
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0 Save Chapters
? '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{' '}
const chaptersWithTitles = clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length; {clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the
if (chaptersWithTitles === 0) { database.
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,8 +147,15 @@ const useVideoChapters = () => {
initialSegments.push(segment); initialSegments.push(segment);
} }
} else { } else {
// Start with empty state - no default segment
initialSegments = []; const initialSegment: Segment = {
id: 1,
chapterTitle: '',
startTime: 0,
endTime: video.duration,
};
initialSegments = [initialSegment];
} }
// Initialize history state with the segments // Initialize history state with the segments
@ -267,17 +274,24 @@ 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 with empty state'); logger.debug('Safari: Successfully initialized metadata, creating default segment');
const defaultSegment: Segment = {
id: 1,
chapterTitle: '',
startTime: 0,
endTime: video.duration,
};
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
setClipSegments([]); setClipSegments([defaultSegment]);
const initialState: EditorState = { const initialState: EditorState = {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [], clipSegments: [defaultSegment],
}; };
setHistory([initialState]); setHistory([initialState]);
@ -666,13 +680,21 @@ 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 (newSegments.length === 0) { // If all segments are deleted, create a new full video segment
// Allow empty state - no segments if (newSegments.length === 0 && videoRef.current) {
setClipSegments([]); // Create a new default segment that spans the entire video
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 || 0); setTrimEnd(videoRef.current.duration);
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);
@ -745,8 +767,17 @@ const useVideoChapters = () => {
setTrimEnd(duration); setTrimEnd(duration);
setSplitPoints([]); setSplitPoints([]);
// Reset to empty state - no default segment // Create a new default segment that spans the entire video
setClipSegments([]); if (!videoRef.current) return;
const defaultSegment: Segment = {
id: Date.now(),
chapterTitle: 'Chapter 1',
startTime: 0,
endTime: duration,
};
setClipSegments([defaultSegment]);
saveState('reset_all'); saveState('reset_all');
}; };
@ -887,7 +918,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
let backendChapters = chapters const backendChapters = chapters
.map((chapter) => ({ .map((chapter) => ({
startTime: chapter.from, startTime: chapter.from,
endTime: chapter.to, endTime: chapter.to,
@ -900,21 +931,6 @@ 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,12 +990,8 @@ 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');
} }
} }
} }
@ -1015,12 +1011,10 @@ class CustomSettingsMenu extends Component {
this.refreshSubtitlesSubmenu(); this.refreshSubtitlesSubmenu();
} }
// Mark settings button as active and hide tooltip // Mark settings button as active
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');
} }
} }
@ -1038,12 +1032,10 @@ 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 and restore tooltip // Remove active state from settings button
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
@ -1079,8 +1071,8 @@ class CustomSettingsMenu extends Component {
const speedLabel = speed === 1 ? 'Normal' : `${speed}`; const speedLabel = speed === 1 ? 'Normal' : `${speed}`;
currentSpeedDisplay.textContent = speedLabel; currentSpeedDisplay.textContent = speedLabel;
// Close the entire settings menu after speed selection // Close only the speed submenu (keep overlay open)
this.closeMenu(); this.speedSubmenu.style.display = 'none';
} }
handleQualityChange(value, qualityOption) { handleQualityChange(value, qualityOption) {
@ -1257,9 +1249,6 @@ 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
@ -1269,10 +1258,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) {
@ -1313,8 +1302,16 @@ 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 menu after subtitle selection // Close the entire settings overlay after subtitle selection
this.closeMenu(); this.settingsOverlay.classList.remove('show');
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;
// Calculate total frames based on video duration vs frame interval // Try to detect total frames based on video duration vs frame interval
const videoDuration = this.player().duration(); const videoDuration = this.player().duration() || 45; // fallback duration
if (!videoDuration) return; const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval);
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);
// Frames are arranged vertically (1 column, multiple rows) // Based on the sprite image you shared, it appears to have frames arranged vertically
const frameRow = frameIndex; // Let's try a vertical layout first (1 column, multiple rows)
const frameCol = 0; const frameRow = frameIndex; // Each frame is on its own row
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,6 +337,12 @@ 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,15 +38,18 @@ 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;
} }
@ -194,20 +197,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;
// Calculate total frames based on video duration vs frame interval // Try to detect total frames based on video duration vs frame interval
const videoDuration = this.player().duration(); const videoDuration = this.player().duration() || 45; // fallback duration
if (!videoDuration) return; const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval);
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);
// Frames are arranged vertically (1 column, multiple rows) // Based on the sprite image, it appears to have frames arranged vertically
const frameRow = frameIndex; // Let's try a vertical layout first (1 column, multiple rows)
const frameCol = 0; const frameRow = frameIndex; // Each frame is on its own row
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);
@ -224,6 +227,12 @@ 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

@ -0,0 +1,689 @@
/* ===== 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

@ -0,0 +1,377 @@
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,28 +340,9 @@ 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];
@ -420,17 +401,13 @@ 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); }, 600); // Increased to 3 seconds
// If not found and we haven't tried too many times, try again with longer delay // If not found and we haven't tried too many times, try again
if (!found && attempt < maxAttempts) { if (!found && attempt < 5) {
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000); setTimeout(() => attemptToApplySubtitles(attempt + 1), attempt * 50);
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
} else if (!found) { } else if (!found) {
// 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); 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;
} }
@ -451,9 +428,7 @@ 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 { } catch (e) {}
// 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