Compare commits

...

15 Commits

Author SHA1 Message Date
Yiannis Christodoulou
567bb18e91 build assets 2025-10-19 20:30:13 +03:00
Yiannis Christodoulou
22e3d9d708 Remove default segments fallback in TimelineControls
Updated TimelineControls to skip loading default segments when no saved segments are found, relying on useVideoTrimmer to create the initial segment. This simplifies the logic and avoids redundant segment creation.
2025-10-19 20:26:24 +03:00
Yiannis Christodoulou
76341b11fa build assets 2025-10-19 20:11:17 +03:00
Yiannis Christodoulou
7343817393 Update chapter handling for empty state and default segment
Simplifies logic in TimelineControls to use clipSegments.length for button and modal text, instead of filtering for titled chapters. In useVideoChapters, ensures a default segment spanning the entire video is created when no chapters exist, improving initial state handling and Safari compatibility.
2025-10-19 20:08:53 +03:00
Yiannis Christodoulou
ce0ae1a8da build assets 2025-10-19 14:24:43 +03:00
Yiannis Christodoulou
67ba1cd467 Refactor audio poster handling and Vite config
Audio poster image is now imported as a module and referenced via AUDIO_POSTER_URL in both chapters-editor and video-editor players. Removed custom file copy plugin from Vite config, updated asset handling to use Vite's asset import and output options, and added type declarations for image modules.
2025-10-19 14:23:17 +03:00
Yiannis Christodoulou
95cb6904ce poster image fix 2025-10-19 14:19:13 +03:00
Yiannis Christodoulou
0a68060700 Update .gitignore 2025-10-19 14:05:02 +03:00
Yiannis Christodoulou
6dc6b3e983 build assets 2025-10-19 14:05:00 +03:00
Yiannis Christodoulou
e0f13e9635 Add keyboard shortcuts for video playback
Implemented keyboard shortcuts for play/pause (spacebar) and seeking backward/forward (arrow keys) in the video editor. Shortcuts are disabled when typing in input fields to prevent interference.
2025-10-19 14:03:23 +03:00
Yiannis Christodoulou
0fac57ee9b Add posterUrl to MEDIA_DATA global object
Introduces a new optional 'posterUrl' property to the MEDIA_DATA object on the window, updating both the runtime initialization and TypeScript type declaration.
2025-10-19 13:56:47 +03:00
Yiannis Christodoulou
1de914ec02 Update .prettierignore 2025-10-19 13:56:35 +03:00
Yiannis Christodoulou
653f7c0418 Update .gitignore 2025-10-19 13:56:30 +03:00
Yiannis Christodoulou
54e2f1d7e4 Add audio poster support for audio files in video players
Introduces an audio-poster.jpg image and updates both chapters and video editor React video player components to display a poster image for audio files when no poster is provided. Also adds a posterUrl field to MEDIA_DATA and ensures fallback logic for poster images is consistent across iOS and standard video players.
2025-10-19 13:56:17 +03:00
Yiannis Christodoulou
bfcb774183 Add posterUrl to MEDIA_DATA in edit templates
Included the posterUrl property in the MEDIA_DATA object for both edit_chapters.html and edit_video.html templates to provide access to the media poster image in client-side scripts.
2025-10-19 13:49:34 +03:00
29 changed files with 359 additions and 197 deletions

4
.gitignore vendored
View File

@ -31,3 +31,7 @@ static/video_editor/videos/sample-video-37s.mp4
static/video_editor/videos/sample-video-10m.mp4 static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4 static/video_editor/videos/sample-video-10s.mp4
frontend-tools/video-js/public/videos/sample-video-white.mp4 frontend-tools/video-js/public/videos/sample-video-white.mp4
frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3

View File

@ -1 +1,3 @@
/templates/cms/* /templates/cms/*
/templates/*.html
*.scss

View File

@ -1 +1 @@
VERSION = "6.7.1.beta-8" VERSION = "6.7.1.beta-9"

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -0,0 +1,6 @@
// Import the audio poster image as a module
// Vite will handle this and provide the correct URL
import audioPosterJpg from '../../public/audio-poster.jpg';
export const AUDIO_POSTER_URL = audioPosterJpg;

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils'; import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import '../styles/IOSVideoPlayer.css'; import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps { interface IOSVideoPlayerProps {
@ -11,6 +12,7 @@ interface IOSVideoPlayerProps {
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => { const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@ -26,15 +28,25 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
// Get the video source URL from the main player // Get the video source URL from the main player
useEffect(() => { useEffect(() => {
let url = '';
if (videoRef.current && videoRef.current.querySelector('source')) { if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement; const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) { if (source && source.src) {
setVideoUrl(source.src); url = source.src;
} }
} else { } else {
// Fallback to sample video if needed // Fallback to sample video if needed
setVideoUrl('/videos/sample-video.mp4'); url = '/videos/sample-video.mp4';
} }
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@ -127,6 +139,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
x-webkit-airplay="allow" x-webkit-airplay="allow"
preload="auto" preload="auto"
crossOrigin="anonymous" crossOrigin="anonymous"
poster={posterImage}
> >
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>

View File

@ -3947,11 +3947,11 @@ 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={clipSegments.length === 0
? "Clear all chapters" ? "Clear all chapters"
: "Save chapters"} : "Save chapters"}
> >
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'
: 'Save Chapters'} : 'Save Chapters'}
</button> </button>
@ -3974,7 +3974,7 @@ 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 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'
: 'Save Chapters'} : 'Save Chapters'}
</button> </button>
@ -3982,14 +3982,9 @@ const TimelineControls = ({
} }
> >
<p className="modal-message"> <p className="modal-message">
{(() => { {clipSegments.length === 0
const chaptersWithTitles = clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length; ? "Are you sure you want to clear all chapters? This will remove all existing chapters from the database."
if (chaptersWithTitles === 0) { : `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.`}
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

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils'; import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import logger from '../lib/logger'; import logger from '../lib/logger';
import '../styles/VideoPlayer.css'; import '../styles/VideoPlayer.css';
@ -38,6 +39,14 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const sampleVideoUrl = const sampleVideoUrl =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4'; (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device and Safari browser // Detect iOS device and Safari browser
useEffect(() => { useEffect(() => {
const checkIOS = () => { const checkIOS = () => {
@ -354,6 +363,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
x-webkit-airplay="allow" x-webkit-airplay="allow"
controls={false} controls={false}
muted={isMuted} muted={isMuted}
poster={posterImage}
> >
<source src={sampleVideoUrl} type="video/mp4" /> <source src={sampleVideoUrl} type="video/mp4" />
{/* Safari fallback for audio files */} {/* Safari fallback for audio files */}

View File

@ -147,8 +147,15 @@ const useVideoChapters = () => {
initialSegments.push(segment); initialSegments.push(segment);
} }
} else { } else {
// Start with empty state - no default segment // Create a default segment that spans the entire video on first load
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]);

View File

@ -6,6 +6,7 @@ if (typeof window !== 'undefined') {
window.MEDIA_DATA = { window.MEDIA_DATA = {
videoUrl: '', videoUrl: '',
mediaId: '', mediaId: '',
posterUrl: ''
}; };
window.lastSeekedPosition = 0; window.lastSeekedPosition = 0;
} }
@ -15,6 +16,7 @@ declare global {
MEDIA_DATA: { MEDIA_DATA: {
videoUrl: string; videoUrl: string;
mediaId: string; mediaId: string;
posterUrl?: string;
}; };
seekToFunction?: (time: number) => void; seekToFunction?: (time: number) => void;
lastSeekedPosition: number; lastSeekedPosition: number;

View File

@ -0,0 +1,32 @@
/// <reference types="vite/client" />
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}

View File

@ -32,8 +32,14 @@ export default defineConfig({
output: { output: {
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'chapters-editor.css'; if (assetInfo.name === 'style.css') return 'chapters-editor.css';
return assetInfo.name; // Keep original names for image assets
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
return assetInfo.name;
}
return assetInfo.name || 'asset-[hash][extname]';
}, },
// Inline small assets, emit larger ones
inlineDynamicImports: true,
globals: { globals: {
react: 'React', react: 'React',
'react-dom': 'ReactDOM', 'react-dom': 'ReactDOM',
@ -44,5 +50,7 @@ export default defineConfig({
outDir: '../../../static/video_editor', outDir: '../../../static/video_editor',
emptyOutDir: true, emptyOutDir: true,
external: ['react', 'react-dom'], external: ['react', 'react-dom'],
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
}, },
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -236,6 +236,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 ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} /> <MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />

View File

@ -0,0 +1,6 @@
// Import the audio poster image as a module
// Vite will handle this and provide the correct URL
import audioPosterJpg from '../../public/audio-poster.jpg';
export const AUDIO_POSTER_URL = audioPosterJpg;

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils'; import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import '../styles/IOSVideoPlayer.css'; import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps { interface IOSVideoPlayerProps {
@ -11,6 +12,7 @@ interface IOSVideoPlayerProps {
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => { const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@ -26,15 +28,25 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
// Get the video source URL from the main player // Get the video source URL from the main player
useEffect(() => { useEffect(() => {
let url = '';
if (videoRef.current && videoRef.current.querySelector('source')) { if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement; const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) { if (source && source.src) {
setVideoUrl(source.src); url = source.src;
} }
} else { } else {
// Fallback to sample video if needed // Fallback to sample video if needed
setVideoUrl('/videos/sample-video.mp4'); url = '/videos/sample-video.mp3';
} }
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@ -127,6 +139,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
x-webkit-airplay="allow" x-webkit-airplay="allow"
preload="auto" preload="auto"
crossOrigin="anonymous" crossOrigin="anonymous"
poster={posterImage}
> >
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>

View File

@ -3,7 +3,7 @@ import { formatTime, formatDetailedTime } from '../lib/timeUtils';
import { generateThumbnail, generateSolidColor } from '../lib/videoUtils'; import { generateThumbnail, generateSolidColor } from '../lib/videoUtils';
import { Segment } from './ClipSegments'; import { Segment } from './ClipSegments';
import Modal from './Modal'; import Modal from './Modal';
import { trimVideo, autoSaveVideo, fetchAutoSavedSegments } from '../services/videoApi'; import { trimVideo, autoSaveVideo } from '../services/videoApi';
import logger from '../lib/logger'; import logger from '../lib/logger';
import '../styles/TimelineControls.css'; import '../styles/TimelineControls.css';
import '../styles/TwoRowTooltip.css'; import '../styles/TwoRowTooltip.css';
@ -1078,44 +1078,10 @@ const TimelineControls = ({
// Get savedSegments directly from window.MEDIA_DATA // Get savedSegments directly from window.MEDIA_DATA
let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.savedSegments) || null; let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.savedSegments) || null;
// If no saved segments, use default segments // If no saved segments, don't load anything - the useVideoTrimmer hook already creates the initial full-length segment
if (!savedData) { if (!savedData) {
logger.debug('No saved segments found in MEDIA_DATA, using default segments'); logger.debug('No saved segments found in MEDIA_DATA, skipping load (initial segment already created by useVideoTrimmer)');
savedData = { return;
segments: [
{
startTime: '00:00:01.130',
endTime: '00:00:05.442',
name: 'segment',
},
{
startTime: '00:00:06.152',
endTime: '00:00:10.518',
name: 'segment',
},
{
startTime: '00:00:11.518',
endTime: '00:00:15.121',
name: 'segment',
},
{
startTime: '00:00:16.757',
endTime: '00:00:20.769',
name: 'segment',
},
{
startTime: '00:00:21.158',
endTime: '00:00:25.870',
name: 'segment',
},
{
startTime: '00:00:26.430',
endTime: '00:00:29.798',
name: 'segment',
},
],
updated_at: '2025-06-24 14:59:14',
};
} }
logger.debug('Loading saved segments:', savedData); logger.debug('Loading saved segments:', savedData);
@ -1132,7 +1098,6 @@ const TimelineControls = ({
endTime: parseTimeString(seg.endTime), endTime: parseTimeString(seg.endTime),
thumbnail: '', thumbnail: '',
})); }));
console.log('convertedSegments', convertedSegments);
// Dispatch event to update segments // Dispatch event to update segments
const updateEvent = new CustomEvent('update-segments', { const updateEvent = new CustomEvent('update-segments', {

View File

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils'; import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import logger from '../lib/logger'; import logger from '../lib/logger';
import '../styles/VideoPlayer.css'; import '../styles/VideoPlayer.css';
@ -36,7 +37,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [tooltipTime, setTooltipTime] = useState(0); const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl = const sampleVideoUrl =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4'; (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp3';
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device // Detect iOS device
useEffect(() => { useEffect(() => {
@ -344,6 +353,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
x-webkit-airplay="allow" x-webkit-airplay="allow"
controls={false} controls={false}
muted={isMuted} muted={isMuted}
poster={posterImage}
> >
<source src={sampleVideoUrl} type="video/mp4" /> <source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>

View File

@ -5,7 +5,8 @@ import "./index.css";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.MEDIA_DATA = { window.MEDIA_DATA = {
videoUrl: "", videoUrl: "",
mediaId: "" mediaId: "",
posterUrl: ""
}; };
window.lastSeekedPosition = 0; window.lastSeekedPosition = 0;
} }
@ -15,6 +16,7 @@ declare global {
MEDIA_DATA: { MEDIA_DATA: {
videoUrl: string; videoUrl: string;
mediaId: string; mediaId: string;
posterUrl?: string;
}; };
seekToFunction?: (time: number) => void; seekToFunction?: (time: number) => void;
lastSeekedPosition: number; lastSeekedPosition: number;

View File

@ -0,0 +1,32 @@
/// <reference types="vite/client" />
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}

View File

@ -32,11 +32,17 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
output: { output: {
// Ensure CSS file has a predictable name // Ensure CSS file has a predictable name and keep image assets
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'video-editor.css'; if (assetInfo.name === 'style.css') return 'video-editor.css';
return assetInfo.name; // Keep original names for image assets
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
return assetInfo.name;
}
return assetInfo.name || 'asset-[hash][extname]';
}, },
// Inline small assets, emit larger ones
inlineDynamicImports: true,
// Add this to ensure the final bundle exposes React correctly // Add this to ensure the final bundle exposes React correctly
globals: { globals: {
'react': 'React', 'react': 'React',
@ -47,6 +53,8 @@ export default defineConfig({
// Output to Django's static directory // Output to Django's static directory
outDir: '../../../static/video_editor', outDir: '../../../static/video_editor',
emptyOutDir: true, emptyOutDir: true,
external: ['react', 'react-dom'] external: ['react', 'react-dom'],
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
}, },
}); });

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

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

View File

@ -13,13 +13,12 @@
<script> <script>
window.MEDIA_DATA = { window.MEDIA_DATA = {
videoUrl: "{{ media_file_path }}", videoUrl: "{{ media_file_path }}",
posterUrl: "{{ media_object.poster_url }}",
mediaId: "{{ media_object.friendly_token }}", mediaId: "{{ media_object.friendly_token }}",
redirectURL: "{{ media_object.get_absolute_url }}", redirectURL: "{{ media_object.get_absolute_url }}",
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}", redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}",
chapters: {{ chapters|safe }}, chapters: {{ chapters|safe }},
}; };
console.log(window.MEDIA_DATA.chapters)
</script> </script>
{%endblock topimports %} {%endblock topimports %}

View File

@ -13,6 +13,7 @@
<script> <script>
window.MEDIA_DATA = { window.MEDIA_DATA = {
videoUrl: "{{ media_file_path }}", videoUrl: "{{ media_file_path }}",
posterUrl: "{{ media_object.poster_url }}",
mediaId: "{{ media_object.friendly_token }}", mediaId: "{{ media_object.friendly_token }}",
redirectURL: "{{ media_object.get_absolute_url }}", redirectURL: "{{ media_object.get_absolute_url }}",
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}" redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}"