mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-05 23:18:53 -05:00
Compare commits
24 Commits
2b7fdca417
...
e6b5023b97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6b5023b97 | ||
|
|
4772c11305 | ||
|
|
41771442d9 | ||
|
|
0854eabd7f | ||
|
|
03d62909b4 | ||
|
|
48e632c17f | ||
|
|
06baf6b1e6 | ||
|
|
af7b4f6212 | ||
|
|
133e147b32 | ||
|
|
84ed74d40d | ||
|
|
b186bbe669 | ||
|
|
5a282c7cd2 | ||
|
|
ac2aee8b8b | ||
|
|
219e80e9e2 | ||
|
|
d955576a7e | ||
|
|
166882558d | ||
|
|
aa458a1a31 | ||
|
|
085e944861 | ||
|
|
b330567955 | ||
|
|
1e8b1ea839 | ||
|
|
276bf6a875 | ||
|
|
6b68e6537f | ||
|
|
88d6ef3700 | ||
|
|
211a442f29 |
@ -1 +1 @@
|
||||
VERSION = "6.7.1.beta-6"
|
||||
VERSION = "6.7.1.beta-8"
|
||||
|
||||
@ -6,6 +6,7 @@ import EditingTools from '@/components/EditingTools';
|
||||
import ClipSegments from '@/components/ClipSegments';
|
||||
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
|
||||
import useVideoChapters from '@/hooks/useVideoChapters';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
@ -39,9 +40,10 @@ const App = () => {
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
initializeSafariIfNeeded,
|
||||
} = useVideoChapters();
|
||||
|
||||
const handlePlay = () => {
|
||||
const handlePlay = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
@ -54,6 +56,16 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safari: Try to initialize if needed before playing
|
||||
if (duration === 0) {
|
||||
const initialized = await initializeSafariIfNeeded();
|
||||
if (initialized) {
|
||||
// Wait a moment for initialization to complete
|
||||
setTimeout(() => handlePlay(), 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start playing - no boundary checking, play through entire timeline
|
||||
video
|
||||
.play()
|
||||
@ -67,6 +79,46 @@ const App = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't handle keyboard shortcuts if user is typing in an input field
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
|
||||
handlePlay();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
@ -9,7 +9,7 @@ interface MobilePlayPromptProps {
|
||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if the device is mobile
|
||||
// Check if the device is mobile or Safari browser
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
@ -18,7 +18,7 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
// Only show for mobile devices
|
||||
const isMobile = checkIsMobile();
|
||||
setIsVisible(isMobile);
|
||||
}, []);
|
||||
@ -49,22 +49,6 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
{/* <h3>Mobile Device Notice</h3>
|
||||
|
||||
<p>
|
||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
||||
using the timeline controls.
|
||||
</p>
|
||||
|
||||
<div className="mobile-prompt-instructions">
|
||||
<p>Please follow these steps:</p>
|
||||
<ol>
|
||||
<li>Tap the button below to start the video</li>
|
||||
<li>After the video starts, you can pause it</li>
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
|
||||
@ -190,12 +190,14 @@ const TimelineControls = ({
|
||||
try {
|
||||
setIsAutoSaving(true);
|
||||
|
||||
// Format segments data for API request - use ref to get latest segments
|
||||
const chapters = clipSegmentsRef.current.map((chapter) => ({
|
||||
startTime: formatDetailedTime(chapter.startTime),
|
||||
endTime: formatDetailedTime(chapter.endTime),
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}));
|
||||
// Format segments data for API request - use ref to get latest segments and sort by start time
|
||||
const chapters = clipSegmentsRef.current
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((chapter) => ({
|
||||
startTime: formatDetailedTime(chapter.startTime),
|
||||
endTime: formatDetailedTime(chapter.endTime),
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}));
|
||||
|
||||
logger.debug('chapters', chapters);
|
||||
|
||||
@ -266,7 +268,13 @@ const TimelineControls = ({
|
||||
// Update editing title when selected segment changes
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
setEditingChapterTitle(selectedSegment.chapterTitle || '');
|
||||
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
|
||||
const isDefaultChapterName = selectedSegment.chapterTitle &&
|
||||
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
|
||||
|
||||
// If it's a default name, show empty string so placeholder appears
|
||||
// If it's a custom title, show the actual title
|
||||
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
|
||||
} else {
|
||||
setEditingChapterTitle('');
|
||||
}
|
||||
@ -490,9 +498,10 @@ const TimelineControls = ({
|
||||
setSaveType('chapters');
|
||||
|
||||
try {
|
||||
// Format chapters data for API request
|
||||
// Format chapters data for API request - sort by start time first
|
||||
const chapters = clipSegments
|
||||
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
|
||||
.sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically
|
||||
.map((segment) => ({
|
||||
chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`,
|
||||
from: formatDetailedTime(segment.startTime),
|
||||
|
||||
@ -38,14 +38,24 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
||||
|
||||
// Detect iOS device
|
||||
// Detect iOS device and Safari browser
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
const checkSafari = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||
};
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
// Store Safari detection globally for other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).isSafari = checkSafari();
|
||||
}
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -336,7 +346,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
@ -346,7 +356,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
muted={isMuted}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
{/* Safari fallback for audio files */}
|
||||
<source src={sampleVideoUrl} type="audio/mp4" />
|
||||
<source src={sampleVideoUrl} type="audio/mpeg" />
|
||||
<p>Your browser doesn't support HTML5 video or audio.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
|
||||
@ -24,6 +24,18 @@ const useVideoChapters = () => {
|
||||
return `Chapter ${chapterIndex + 1}`;
|
||||
};
|
||||
|
||||
// Helper function to renumber all segments in chronological order
|
||||
const renumberAllSegments = (segments: Segment[]): Segment[] => {
|
||||
// Sort segments by start time
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Renumber each segment based on its chronological position
|
||||
return sortedSegments.map((segment, index) => ({
|
||||
...segment,
|
||||
chapterTitle: `Chapter ${index + 1}`
|
||||
}));
|
||||
};
|
||||
|
||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||
const parseTimeToSeconds = (timeString: string): number => {
|
||||
const parts = timeString.split(':');
|
||||
@ -87,12 +99,23 @@ const useVideoChapters = () => {
|
||||
}
|
||||
}, [history, historyPosition]);
|
||||
|
||||
// Detect Safari browser
|
||||
const isSafari = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||
if (isSafariBrowser) {
|
||||
logger.debug('Safari browser detected, enabling audio support fallbacks');
|
||||
}
|
||||
return isSafariBrowser;
|
||||
};
|
||||
|
||||
// Initialize video event listeners
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
logger.debug('Video loadedmetadata event fired, duration:', video.duration);
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
|
||||
@ -146,11 +169,32 @@ const useVideoChapters = () => {
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
setClipSegments(initialSegments);
|
||||
logger.debug('Editor initialized with segments:', initialSegments.length);
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
};
|
||||
|
||||
// Safari-specific fallback for audio files
|
||||
const handleCanPlay = () => {
|
||||
logger.debug('Video canplay event fired');
|
||||
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization
|
||||
if (video.duration && duration === 0) {
|
||||
logger.debug('Safari fallback: Using canplay event to initialize');
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
};
|
||||
|
||||
// Additional Safari fallback for audio files
|
||||
const handleLoadedData = () => {
|
||||
logger.debug('Video loadeddata event fired');
|
||||
// If we still don't have duration, try again
|
||||
if (video.duration && duration === 0) {
|
||||
logger.debug('Safari fallback: Using loadeddata event to initialize');
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
@ -176,6 +220,33 @@ const useVideoChapters = () => {
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
// Safari-specific fallback event listeners for audio files
|
||||
if (isSafari()) {
|
||||
logger.debug('Adding Safari-specific event listeners for audio support');
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
|
||||
// Additional timeout fallback for Safari audio files
|
||||
const safariTimeout = setTimeout(() => {
|
||||
if (video.duration && duration === 0) {
|
||||
logger.debug('Safari timeout fallback: Force initializing editor');
|
||||
handleLoadedMetadata();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
// Remove event listeners
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
clearTimeout(safariTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Remove event listeners
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
@ -186,6 +257,92 @@ const useVideoChapters = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Safari auto-initialization on user interaction
|
||||
useEffect(() => {
|
||||
if (isSafari() && videoRef.current) {
|
||||
const video = videoRef.current;
|
||||
|
||||
const initializeSafariOnInteraction = () => {
|
||||
// Try to load video metadata by attempting to play and immediately pause
|
||||
const attemptInitialization = async () => {
|
||||
try {
|
||||
logger.debug('Safari: Attempting auto-initialization on user interaction');
|
||||
|
||||
// Briefly play to trigger metadata loading, then pause
|
||||
await video.play();
|
||||
video.pause();
|
||||
|
||||
// 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');
|
||||
|
||||
const defaultSegment: Segment = {
|
||||
id: 1,
|
||||
chapterTitle: '',
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
};
|
||||
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
setClipSegments([defaultSegment]);
|
||||
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [defaultSegment],
|
||||
};
|
||||
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Safari: Auto-initialization failed, will retry on next interaction:', error);
|
||||
}
|
||||
};
|
||||
|
||||
attemptInitialization();
|
||||
};
|
||||
|
||||
// Listen for any user interaction with video controls
|
||||
const handleUserInteraction = () => {
|
||||
if (clipSegments.length === 0 && video.duration === 0) {
|
||||
initializeSafariOnInteraction();
|
||||
}
|
||||
};
|
||||
|
||||
// Add listeners for various user interactions
|
||||
document.addEventListener('click', handleUserInteraction);
|
||||
document.addEventListener('keydown', handleUserInteraction);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleUserInteraction);
|
||||
document.removeEventListener('keydown', handleUserInteraction);
|
||||
};
|
||||
}
|
||||
}, [clipSegments.length]);
|
||||
|
||||
// Safari initialization helper
|
||||
const initializeSafariIfNeeded = async () => {
|
||||
if (isSafari() && videoRef.current && duration === 0) {
|
||||
const video = videoRef.current;
|
||||
try {
|
||||
logger.debug('Safari: Initializing on user interaction');
|
||||
// This play/pause will trigger metadata loading in Safari
|
||||
await video.play();
|
||||
video.pause();
|
||||
|
||||
// The metadata events should fire now and initialize segments
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug('Safari: Initialization attempt failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Play/pause video
|
||||
const playPauseVideo = () => {
|
||||
const video = videoRef.current;
|
||||
@ -194,6 +351,21 @@ const useVideoChapters = () => {
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
// Safari: Try to initialize if needed before playing
|
||||
if (isSafari() && duration === 0) {
|
||||
initializeSafariIfNeeded().then(() => {
|
||||
// After initialization, try to play again
|
||||
setTimeout(() => {
|
||||
if (video && !isPlaying) {
|
||||
video.play().catch((err) => {
|
||||
console.error('Error playing after Safari initialization:', err);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS Safari fix: Use the last seeked position if available
|
||||
if (!isPlaying && typeof window !== 'undefined' && window.lastSeekedPosition > 0) {
|
||||
// Only apply this if the video is not at the same position already
|
||||
@ -228,6 +400,20 @@ const useVideoChapters = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Safari: Try to initialize if needed before seeking
|
||||
if (isSafari() && duration === 0) {
|
||||
initializeSafariIfNeeded().then(() => {
|
||||
// After initialization, try to seek again
|
||||
setTimeout(() => {
|
||||
if (video) {
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track if the video was playing before seeking
|
||||
const wasPlaying = !video.paused;
|
||||
|
||||
@ -458,19 +644,16 @@ const useVideoChapters = () => {
|
||||
|
||||
newSegments.splice(segmentIndex, 1);
|
||||
|
||||
// Remove the original segment first to get accurate positioning for new segments
|
||||
const segmentsWithoutOriginal = newSegments;
|
||||
|
||||
const firstHalf: Segment = {
|
||||
id: Date.now(),
|
||||
chapterTitle: generateChapterName(segmentToSplit.startTime, segmentsWithoutOriginal),
|
||||
chapterTitle: '', // Temporary title, will be set by renumberAllSegments
|
||||
startTime: segmentToSplit.startTime,
|
||||
endTime: timeToSplit,
|
||||
};
|
||||
|
||||
const secondHalf: Segment = {
|
||||
id: Date.now() + 1,
|
||||
chapterTitle: generateChapterName(timeToSplit, [...segmentsWithoutOriginal, firstHalf]),
|
||||
chapterTitle: '', // Temporary title, will be set by renumberAllSegments
|
||||
startTime: timeToSplit,
|
||||
endTime: segmentToSplit.endTime,
|
||||
};
|
||||
@ -478,11 +661,11 @@ const useVideoChapters = () => {
|
||||
// Add the new segments
|
||||
newSegments.push(firstHalf, secondHalf);
|
||||
|
||||
// Sort segments by start time
|
||||
newSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
// Renumber all segments to ensure proper chronological naming
|
||||
const renumberedSegments = renumberAllSegments(newSegments);
|
||||
|
||||
// Update state
|
||||
setClipSegments(newSegments);
|
||||
setClipSegments(renumberedSegments);
|
||||
saveState('split_segment');
|
||||
}
|
||||
};
|
||||
@ -513,8 +696,9 @@ const useVideoChapters = () => {
|
||||
setSplitPoints([]);
|
||||
setClipSegments([defaultSegment]);
|
||||
} else {
|
||||
// Just update the segments normally
|
||||
setClipSegments(newSegments);
|
||||
// Renumber remaining segments to ensure proper chronological naming
|
||||
const renumberedSegments = renumberAllSegments(newSegments);
|
||||
setClipSegments(renumberedSegments);
|
||||
}
|
||||
saveState('delete_segment');
|
||||
}
|
||||
@ -733,12 +917,19 @@ const useVideoChapters = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert chapters to backend expected format
|
||||
const backendChapters = chapters.map((chapter) => ({
|
||||
startTime: chapter.from,
|
||||
endTime: chapter.to,
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}));
|
||||
// Convert chapters to backend expected format and sort by start time
|
||||
const backendChapters = chapters
|
||||
.map((chapter) => ({
|
||||
startTime: chapter.from,
|
||||
endTime: chapter.to,
|
||||
chapterTitle: chapter.chapterTitle,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Parse time strings to seconds for proper comparison
|
||||
const aStartSeconds = parseTimeToSeconds(a.startTime);
|
||||
const bStartSeconds = parseTimeToSeconds(b.startTime);
|
||||
return aStartSeconds - bStartSeconds;
|
||||
});
|
||||
|
||||
// Create the API request body
|
||||
const requestData = {
|
||||
@ -946,6 +1137,7 @@ const useVideoChapters = () => {
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
initializeSafariIfNeeded, // Expose Safari initialization helper
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
BIN
frontend-tools/video-js/public/audio-poster.jpg
Normal file
BIN
frontend-tools/video-js/public/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
1240
frontend-tools/video-js/public/sample-media-file.json
Normal file
1240
frontend-tools/video-js/public/sample-media-file.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,9 @@ class AutoplayToggleButton extends Button {
|
||||
} */
|
||||
|
||||
// Store the appropriate font size based on device type
|
||||
this.iconSize = isTouchDevice ? PlayerConfig.controlBar.mobileFontSize : PlayerConfig.controlBar.fontSize;
|
||||
// PlayerConfig values are in em units, convert to pixels for SVG dimensions
|
||||
const baseFontSize = isTouchDevice ? PlayerConfig.controlBar.mobileFontSize : PlayerConfig.controlBar.fontSize;
|
||||
this.iconSize = Math.round((baseFontSize || 14) * 1.2); // Scale and default to 14em if undefined
|
||||
|
||||
this.userPreferences = options.userPreferences;
|
||||
// Get autoplay preference from localStorage, default to false if not set
|
||||
@ -72,6 +74,11 @@ class AutoplayToggleButton extends Button {
|
||||
}
|
||||
|
||||
updateIconClass() {
|
||||
// Ensure iconSize is a valid number (defensive check)
|
||||
if (!this.iconSize || isNaN(this.iconSize)) {
|
||||
this.iconSize = 16; // Default to 16px if undefined or NaN
|
||||
}
|
||||
|
||||
// Remove existing icon classes
|
||||
this.iconSpan.className = 'vjs-icon-placeholder vjs-svg-icon vjs-autoplay-icon__OFFF';
|
||||
this.iconSpan.style.position = 'relative';
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
z-index: 1000;
|
||||
z-index: 20000;
|
||||
margin-bottom: 10px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
right: 10px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
z-index: 9999;
|
||||
z-index: 10000;
|
||||
font-size: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 4;
|
||||
z-index: 200;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
@ -154,7 +154,7 @@
|
||||
.autoplay-close-button {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
|
||||
.autoplay-cancel-button {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
position: absolute !important;
|
||||
top: 10px !important;
|
||||
left: 10px !important;
|
||||
z-index: 1000 !important;
|
||||
z-index: 5000 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 10px !important;
|
||||
|
||||
@ -5,11 +5,11 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px; /* Leave space for control bar */
|
||||
background: #000000; /* Solid black background */
|
||||
bottom: 60px;
|
||||
background: #000000;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
overflow: hidden; /* Prevent content from overflowing */
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@ -353,9 +353,9 @@
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Ensure end screen overlay covers everything with solid background */
|
||||
/* Ensure end screen overlay covers everything with solid background but stays below menus */
|
||||
.video-js.vjs-ended .vjs-end-screen-overlay {
|
||||
background: #000000 !important;
|
||||
z-index: 99999 !important;
|
||||
z-index: 100 !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.playlist-items a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.video-js,
|
||||
.video-js[tabindex],
|
||||
@ -27,3 +30,12 @@ button {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Center the fullscreen button inside its wrapper */
|
||||
/* @media (hover: hover) and (pointer: fine) {
|
||||
.vjs-fullscreen-control svg {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@ -28,6 +28,12 @@ import { EndScreenHandler } from '../../utils/EndScreenHandler';
|
||||
import KeyboardHandler from '../../utils/KeyboardHandler';
|
||||
import PlaybackEventHandler from '../../utils/PlaybackEventHandler';
|
||||
|
||||
// Import sample media data
|
||||
import sampleMediaData from '../../../public/sample-media-file.json';
|
||||
|
||||
// Import fallback poster image
|
||||
import audioPosterImg from '../../../public/audio-poster.jpg';
|
||||
|
||||
// Function to enable tooltips for all standard VideoJS buttons
|
||||
const enableStandardButtonTooltips = (player) => {
|
||||
// Wait a bit for all components to be initialized
|
||||
@ -243,14 +249,32 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
typeof window !== 'undefined' && window.MEDIA_DATA
|
||||
? window.MEDIA_DATA
|
||||
: {
|
||||
data: {
|
||||
data: sampleMediaData,
|
||||
|
||||
// other
|
||||
useRoundedCorners: false,
|
||||
isPlayList: false,
|
||||
previewSprite: {
|
||||
url: sampleMediaData.sprites_url
|
||||
? 'https://videojs.mediacms.io' + sampleMediaData.sprites_url
|
||||
: 'https://videojs.mediacms.io/media/original/thumbnails/user/admin/43cc73a8c1604425b7057ad2b50b1798.19247660hd_1920_1080_60fps.mp4sprites.jpg',
|
||||
frame: { width: 160, height: 90, seconds: 10 },
|
||||
},
|
||||
siteUrl: 'https://videojs.mediacms.io',
|
||||
nextLink: 'https://videojs.mediacms.io/view?m=elygiagorgechania',
|
||||
urlAutoplay: true,
|
||||
urlMuted: false,
|
||||
|
||||
// FALLBACK - keep old structure for reference
|
||||
__data_old: {
|
||||
// COMMON
|
||||
title: 'Modi tempora est quaerat numquam',
|
||||
author_name: 'Markos Gogoulos',
|
||||
author_profile: '/user/markos/',
|
||||
author_thumbnail: '/media/userlogos/user.jpg',
|
||||
url: 'https://videojs.mediacms.io/view?m=2Uk08Il5u',
|
||||
poster_url:
|
||||
poster_url: '',
|
||||
__poster_url:
|
||||
'/media/original/thumbnails/user/markos/db52140de7204022a1e5f08e078b4ec6_VKPTF4v.UniversityofCopenhagenMærskTower.mp4.jpg',
|
||||
___chapter_data: [],
|
||||
chapter_data: [
|
||||
@ -1248,9 +1272,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
|
||||
// VIDEO
|
||||
media_type: 'audio',
|
||||
_original_media_url:
|
||||
original_media_url:
|
||||
'/media/original/user/markos/db52140de7204022a1e5f08e078b4ec6.UniversityofCopenhagenMærskTower.mp4',
|
||||
_hls_info: {
|
||||
hls_info: {
|
||||
master_file: '/media/hls/5073e97457004961a163c5b504e2d7e8/master.m3u8',
|
||||
'240_iframe': '/media/hls/5073e97457004961a163c5b504e2d7e8/media-1/iframes.m3u8',
|
||||
'480_iframe': '/media/hls/5073e97457004961a163c5b504e2d7e8/media-2/iframes.m3u8',
|
||||
@ -1263,9 +1287,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
'144_playlist': '/media/hls/5073e97457004961a163c5b504e2d7e8/media-4/stream.m3u8',
|
||||
'360_playlist': '/media/hls/5073e97457004961a163c5b504e2d7e8/media-5/stream.m3u8',
|
||||
},
|
||||
hls_info: {},
|
||||
encodings_info: {},
|
||||
___encodings_info: {
|
||||
__hls_info: {},
|
||||
__encodings_info: {},
|
||||
encodings_info: {
|
||||
144: {
|
||||
h264: {
|
||||
title: 'h264-144',
|
||||
@ -1337,18 +1361,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
hls_info: {},
|
||||
encodings_info: {},*/
|
||||
},
|
||||
|
||||
// other
|
||||
useRoundedCorners: false,
|
||||
isPlayList: false,
|
||||
previewSprite: {
|
||||
url: 'https://videojs.mediacms.io/media/original/thumbnails/user/admin/43cc73a8c1604425b7057ad2b50b1798.19247660hd_1920_1080_60fps.mp4sprites.jpg',
|
||||
frame: { width: 160, height: 90, seconds: 10 },
|
||||
},
|
||||
siteUrl: 'https://videojs.mediacms.io',
|
||||
nextLink: 'https://videojs.mediacms.io/view?m=elygiagorgechania',
|
||||
urlAutoplay: true,
|
||||
urlMuted: false,
|
||||
},
|
||||
[]
|
||||
);
|
||||
@ -1656,7 +1668,7 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
? mediaData.siteUrl + mediaData.data.author_thumbnail
|
||||
: '',
|
||||
url: mediaData.data?.url || '',
|
||||
poster: mediaData.data?.poster_url ? mediaData.siteUrl + mediaData.data.poster_url : '',
|
||||
poster: mediaData.data?.poster_url ? mediaData.siteUrl + mediaData.data.poster_url : audioPosterImg,
|
||||
previewSprite: mediaData?.previewSprite || {},
|
||||
useRoundedCorners: mediaData?.useRoundedCorners,
|
||||
isPlayList: mediaData?.isPlayList,
|
||||
@ -2076,7 +2088,7 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
controlBar: {
|
||||
playToggle: true,
|
||||
progressControl: {
|
||||
seekBar: {},
|
||||
seekBar: { loadProgressBar: false }, // Hide the buffered/loaded progress indicator
|
||||
},
|
||||
/* progressControl: {
|
||||
seekBar: {
|
||||
@ -2100,11 +2112,11 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
// Custom control spacer
|
||||
customControlSpacer: true,
|
||||
|
||||
// Fullscreen toggle button
|
||||
fullscreenToggle: true,
|
||||
// Fullscreen toggle button (hide for audio files since fullscreen doesn't work on mobile)
|
||||
fullscreenToggle: mediaData.data?.media_type === 'audio' ? false : true,
|
||||
|
||||
// Picture-in-picture toggle button
|
||||
pictureInPictureToggle: isTouchDevice ? false : true,
|
||||
// Picture-in-picture toggle button (hide for audio and touch devices)
|
||||
pictureInPictureToggle: isTouchDevice || mediaData.data?.media_type === 'audio' ? false : true,
|
||||
|
||||
// Remove default playback speed dropdown from control bar
|
||||
playbackRateMenuButton: false,
|
||||
|
||||
@ -38,9 +38,9 @@ const PlayerConfig = {
|
||||
height: 3,
|
||||
|
||||
// Font size in em units
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
|
||||
mobileFontSize: 10,
|
||||
mobileFontSize: 13,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -25,10 +25,41 @@ export class EndScreenHandler {
|
||||
if (this.autoplayCountdown) {
|
||||
this.autoplayCountdown.stopCountdown();
|
||||
}
|
||||
|
||||
// Reset control bar to normal auto-hide behavior
|
||||
this.resetControlBarBehavior();
|
||||
};
|
||||
|
||||
this.player.on('play', hideEndScreenAndStopCountdown);
|
||||
this.player.on('seeking', hideEndScreenAndStopCountdown);
|
||||
|
||||
// Reset control bar when playing after ended state
|
||||
this.player.on('playing', () => {
|
||||
// Only reset if we're coming from ended state (time near 0)
|
||||
if (this.player.currentTime() < 1) {
|
||||
setTimeout(() => {
|
||||
this.player.userActive(false);
|
||||
}, 1000); // Hide controls after 1 second
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New method to reset control bar to default behavior
|
||||
resetControlBarBehavior() {
|
||||
const controlBar = this.player.getChild('controlBar');
|
||||
if (controlBar && controlBar.el()) {
|
||||
// Remove the forced visible styles
|
||||
controlBar.el().style.opacity = '';
|
||||
controlBar.el().style.pointerEvents = '';
|
||||
|
||||
// Let video.js handle the control bar visibility normally
|
||||
// Force the player to be inactive after a short delay
|
||||
setTimeout(() => {
|
||||
if (!this.player.paused() && !this.player.ended()) {
|
||||
this.player.userActive(false);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
handleVideoEnded() {
|
||||
@ -138,6 +169,8 @@ export class EndScreenHandler {
|
||||
nextVideoData: nextVideoData,
|
||||
countdownSeconds: 5,
|
||||
onPlayNext: () => {
|
||||
// Reset control bar when auto-playing next video
|
||||
this.resetControlBarBehavior();
|
||||
goToNextVideo();
|
||||
},
|
||||
onCancel: () => {
|
||||
@ -171,7 +204,7 @@ export class EndScreenHandler {
|
||||
relatedVideos: relatedVideos,
|
||||
});
|
||||
|
||||
// Also store the data directly on the component as backup and update it
|
||||
// Store the data directly on the component as backup and update it
|
||||
this.endScreen.relatedVideos = relatedVideos;
|
||||
if (this.endScreen.setRelatedVideos) {
|
||||
this.endScreen.setRelatedVideos(relatedVideos);
|
||||
@ -195,5 +228,7 @@ export class EndScreenHandler {
|
||||
|
||||
cleanup() {
|
||||
this.cleanupOverlays();
|
||||
// Reset control bar on cleanup
|
||||
this.resetControlBarBehavior();
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
root: path.resolve(__dirname, 'src'),
|
||||
publicDir: path.resolve(__dirname, 'public'),
|
||||
define: {
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
static/video_js/audio-poster.jpg
Normal file
BIN
static/video_js/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
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