mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-05 15:08:53 -05:00
Compare commits
10 Commits
ca0dc29488
...
4e5b5a3e5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e5b5a3e5b | ||
|
|
192333b8d9 | ||
|
|
a61692aa8c | ||
|
|
08ba5ff74c | ||
|
|
03872d0b25 | ||
|
|
c071524cb9 | ||
|
|
c21317dbb4 | ||
|
|
1887c262d5 | ||
|
|
b2729d16aa | ||
|
|
1a080adddc |
@ -630,7 +630,7 @@ class Media(models.Model):
|
||||
|
||||
@property
|
||||
def trim_video_url(self):
|
||||
if self.media_type not in ["video"]:
|
||||
if self.media_type not in ["video", "audio"]:
|
||||
return None
|
||||
|
||||
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
|
||||
def trim_video_path(self):
|
||||
if self.media_type not in ["video"]:
|
||||
if self.media_type not in ["video", "audio"]:
|
||||
return None
|
||||
|
||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||
|
||||
@ -258,7 +258,7 @@ def video_chapters(request, friendly_token):
|
||||
try:
|
||||
request_data = json.loads(request.body)
|
||||
data = request_data.get("chapters")
|
||||
if not data:
|
||||
if data is None:
|
||||
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
|
||||
|
||||
chapters = []
|
||||
|
||||
@ -508,18 +508,17 @@ const TimelineControls = ({
|
||||
to: formatDetailedTime(segment.endTime),
|
||||
}));
|
||||
|
||||
if (chapters.length === 0) {
|
||||
setErrorMessage('No chapters with titles found');
|
||||
setShowErrorModal(true);
|
||||
setShowProcessingModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow saving even when no chapters exist (will send empty array)
|
||||
// Call the onChapterSave function if provided
|
||||
if (onChapterSave) {
|
||||
await onChapterSave(chapters);
|
||||
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
|
||||
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
|
||||
const remainingSegments = clipSegments.filter((seg) => seg.id !== segmentId);
|
||||
if (remainingSegments.length === 0) {
|
||||
// Create a full video segment
|
||||
const fullVideoSegment: Segment = {
|
||||
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);
|
||||
// Allow empty state - clear all UI state
|
||||
setSelectedSegmentId(null);
|
||||
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),
|
||||
});
|
||||
}
|
||||
setActiveSegment(null);
|
||||
|
||||
logger.debug('All segments deleted - entering empty state');
|
||||
} else if (selectedSegmentId === segmentId) {
|
||||
// Handle normal segment deletion
|
||||
const deletedSegment = clipSegments.find((seg) => seg.id === segmentId);
|
||||
@ -3984,10 +3947,13 @@ const TimelineControls = ({
|
||||
<button
|
||||
onClick={() => setShowSaveChaptersModal(true)}
|
||||
className="save-chapters-button"
|
||||
data-tooltip="Save chapters"
|
||||
disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0}
|
||||
data-tooltip={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>
|
||||
</div>
|
||||
|
||||
@ -4008,15 +3974,22 @@ const TimelineControls = ({
|
||||
className="modal-button modal-button-primary"
|
||||
onClick={handleSaveChaptersConfirm}
|
||||
>
|
||||
Save Chapters
|
||||
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
|
||||
? 'Clear Chapters'
|
||||
: 'Save Chapters'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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
|
||||
database.
|
||||
{(() => {
|
||||
const chaptersWithTitles = clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length;
|
||||
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>
|
||||
</Modal>
|
||||
|
||||
|
||||
@ -147,15 +147,8 @@ const useVideoChapters = () => {
|
||||
initialSegments.push(segment);
|
||||
}
|
||||
} else {
|
||||
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
chapterTitle: '',
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
};
|
||||
|
||||
initialSegments = [initialSegment];
|
||||
// Start with empty state - no default segment
|
||||
initialSegments = [];
|
||||
}
|
||||
|
||||
// Initialize history state with the segments
|
||||
@ -274,24 +267,17 @@ const useVideoChapters = () => {
|
||||
|
||||
// Check if we now have duration and initialize if needed
|
||||
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);
|
||||
setTrimEnd(video.duration);
|
||||
setClipSegments([defaultSegment]);
|
||||
setClipSegments([]);
|
||||
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [defaultSegment],
|
||||
clipSegments: [],
|
||||
};
|
||||
|
||||
setHistory([initialState]);
|
||||
@ -680,21 +666,13 @@ const useVideoChapters = () => {
|
||||
const newSegments = clipSegments.filter((segment) => segment.id !== segmentId);
|
||||
|
||||
if (newSegments.length !== clipSegments.length) {
|
||||
// If all segments are deleted, create a new full video segment
|
||||
if (newSegments.length === 0 && videoRef.current) {
|
||||
// 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,
|
||||
};
|
||||
|
||||
if (newSegments.length === 0) {
|
||||
// Allow empty state - no segments
|
||||
setClipSegments([]);
|
||||
// Reset the trim points as well
|
||||
setTrimStart(0);
|
||||
setTrimEnd(videoRef.current.duration);
|
||||
setTrimEnd(videoRef.current?.duration || 0);
|
||||
setSplitPoints([]);
|
||||
setClipSegments([defaultSegment]);
|
||||
} else {
|
||||
// Renumber remaining segments to ensure proper chronological naming
|
||||
const renumberedSegments = renumberAllSegments(newSegments);
|
||||
@ -767,17 +745,8 @@ const useVideoChapters = () => {
|
||||
setTrimEnd(duration);
|
||||
setSplitPoints([]);
|
||||
|
||||
// Create a new default segment that spans the entire video
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
chapterTitle: 'Chapter 1',
|
||||
startTime: 0,
|
||||
endTime: duration,
|
||||
};
|
||||
|
||||
setClipSegments([defaultSegment]);
|
||||
// Reset to empty state - no default segment
|
||||
setClipSegments([]);
|
||||
saveState('reset_all');
|
||||
};
|
||||
|
||||
@ -918,7 +887,7 @@ const useVideoChapters = () => {
|
||||
}
|
||||
|
||||
// Convert chapters to backend expected format and sort by start time
|
||||
const backendChapters = chapters
|
||||
let backendChapters = chapters
|
||||
.map((chapter) => ({
|
||||
startTime: chapter.from,
|
||||
endTime: chapter.to,
|
||||
@ -931,6 +900,21 @@ const useVideoChapters = () => {
|
||||
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
|
||||
const requestData = {
|
||||
chapters: backendChapters,
|
||||
|
||||
@ -990,8 +990,12 @@ class CustomSettingsMenu extends Component {
|
||||
if (btnEl) {
|
||||
if (!isVisible) {
|
||||
btnEl.classList.add('settings-clicked');
|
||||
// Hide tooltip when menu is open
|
||||
btnEl.removeAttribute('title');
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
|
||||
// Mark settings button as active
|
||||
// Mark settings button as active and hide tooltip
|
||||
const btnEl = this.settingsButton?.el();
|
||||
if (btnEl) {
|
||||
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.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();
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('settings-clicked');
|
||||
// Restore tooltip when menu is closed
|
||||
btnEl.setAttribute('title', 'Settings');
|
||||
}
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
@ -1071,8 +1079,8 @@ class CustomSettingsMenu extends Component {
|
||||
const speedLabel = speed === 1 ? 'Normal' : `${speed}`;
|
||||
currentSpeedDisplay.textContent = speedLabel;
|
||||
|
||||
// Close only the speed submenu (keep overlay open)
|
||||
this.speedSubmenu.style.display = 'none';
|
||||
// Close the entire settings menu after speed selection
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
handleQualityChange(value, qualityOption) {
|
||||
@ -1249,6 +1257,9 @@ class CustomSettingsMenu extends Component {
|
||||
} catch (e) {}
|
||||
|
||||
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
|
||||
@ -1258,10 +1269,10 @@ class CustomSettingsMenu extends Component {
|
||||
player.one('loadeddata', finishRestore);
|
||||
};
|
||||
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) {
|
||||
@ -1302,16 +1313,8 @@ class CustomSettingsMenu extends Component {
|
||||
const currentSubtitlesDisplay = this.settingsOverlay.querySelector('.current-subtitles');
|
||||
if (currentSubtitlesDisplay) currentSubtitlesDisplay.textContent = label;
|
||||
|
||||
// Close the entire settings overlay after subtitle selection
|
||||
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');
|
||||
}
|
||||
// Close the entire settings menu after subtitle selection
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
restoreSubtitlePreference() {
|
||||
|
||||
@ -310,20 +310,20 @@ class ChapterMarkers extends Component {
|
||||
// Use sprite interval from frame data, fallback to 10 seconds
|
||||
const frameInterval = frame.seconds || 10;
|
||||
|
||||
// Try to detect total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration() || 45; // fallback duration
|
||||
const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
const maxFrames = Math.min(calculatedMaxFrames, 6); // Cap at 6 frames to be safe
|
||||
// Calculate total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration();
|
||||
if (!videoDuration) return;
|
||||
|
||||
const maxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
let frameIndex = Math.floor(currentTime / frameInterval);
|
||||
|
||||
// Clamp frameIndex to available frames to prevent showing empty areas
|
||||
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
|
||||
// Let's try a vertical layout first (1 column, multiple rows)
|
||||
const frameRow = frameIndex; // Each frame is on its own row
|
||||
const frameCol = 0; // Always first (and only) column
|
||||
// Frames are arranged vertically (1 column, multiple rows)
|
||||
const frameRow = frameIndex;
|
||||
const frameCol = 0;
|
||||
|
||||
// Calculate background position (negative values to shift the sprite)
|
||||
const xPos = -(frameCol * width);
|
||||
@ -337,12 +337,6 @@ class ChapterMarkers extends Component {
|
||||
|
||||
// Ensure the image is visible
|
||||
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) {
|
||||
|
||||
@ -38,18 +38,15 @@ class SpritePreview extends Component {
|
||||
|
||||
// Try to get progress control from control bar first, then from moved location
|
||||
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 (!progressControl) {
|
||||
// Look for moved progress control in custom components
|
||||
const customComponents = this.player().customComponents || {};
|
||||
progressControl = customComponents.movedProgressControl;
|
||||
console.log('SpritePreview: progressControl from customComponents:', progressControl);
|
||||
}
|
||||
|
||||
if (!progressControl) {
|
||||
console.log('SpritePreview: No progress control found!');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -197,20 +194,20 @@ class SpritePreview extends Component {
|
||||
// Use sprite interval from frame data, fallback to 10 seconds
|
||||
const frameInterval = frame.seconds || 10;
|
||||
|
||||
// Try to detect total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration() || 45; // fallback duration
|
||||
const calculatedMaxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
const maxFrames = Math.min(calculatedMaxFrames, 6); // Cap at 6 frames to be safe
|
||||
// Calculate total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration();
|
||||
if (!videoDuration) return;
|
||||
|
||||
const maxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
let frameIndex = Math.floor(currentTime / frameInterval);
|
||||
|
||||
// Clamp frameIndex to available frames to prevent showing empty areas
|
||||
frameIndex = Math.min(frameIndex, maxFrames - 1);
|
||||
frameIndex = Math.max(frameIndex, 0);
|
||||
|
||||
// Based on the sprite image, it appears to have frames arranged vertically
|
||||
// Let's try a vertical layout first (1 column, multiple rows)
|
||||
const frameRow = frameIndex; // Each frame is on its own row
|
||||
const frameCol = 0; // Always first (and only) column
|
||||
// Frames are arranged vertically (1 column, multiple rows)
|
||||
const frameRow = frameIndex;
|
||||
const frameCol = 0;
|
||||
|
||||
// Calculate background position (negative values to shift the sprite)
|
||||
const xPos = -(frameCol * width);
|
||||
@ -227,12 +224,6 @@ class SpritePreview extends Component {
|
||||
|
||||
// Ensure the image is visible
|
||||
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) {
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@ -340,9 +340,28 @@ class UserPreferences {
|
||||
// Set flag to prevent auto-save during restoration
|
||||
this.isRestoringSubtitles = true;
|
||||
// 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 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
|
||||
for (let i = 0; i < textTracks.length; 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
|
||||
setTimeout(() => {
|
||||
this.isRestoringSubtitles = false;
|
||||
}, 600); // Increased to 3 seconds
|
||||
}, 600);
|
||||
|
||||
// If not found and we haven't tried too many times, try again
|
||||
if (!found && attempt < 5) {
|
||||
setTimeout(() => attemptToApplySubtitles(attempt + 1), attempt * 50);
|
||||
// If not found and we haven't tried too many times, try again with longer delay
|
||||
if (!found && attempt < maxAttempts) {
|
||||
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
|
||||
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
|
||||
} 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
|
||||
this.isRestoringSubtitles = false;
|
||||
}
|
||||
@ -428,7 +451,9 @@ class UserPreferences {
|
||||
ttList.addEventListener('addtrack', onAddTrack, { once: true });
|
||||
ttList.addEventListener('change', onChange, { once: true });
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Silently ignore errors accessing native text track list
|
||||
}
|
||||
} else {
|
||||
// Ensure subtitles are off on load when not enabled
|
||||
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
Loading…
x
Reference in New Issue
Block a user