feat: Chapter editor main functionality and styling

This commit is contained in:
Yiannis Christodoulou 2025-07-28 02:13:09 +03:00
parent 79de619be4
commit 8df5ea880c
8 changed files with 892 additions and 707 deletions

View File

@ -1,12 +1,11 @@
import { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
import logger from "./lib/logger";
import VideoPlayer from "@/components/VideoPlayer";
import TimelineControls from "@/components/TimelineControls";
import EditingTools from "@/components/EditingTools";
import ClipSegments from "@/components/ClipSegments";
import MobilePlayPrompt from "@/components/IOSPlayPrompt";
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
import { formatDetailedTime } from './lib/timeUtils';
import logger from './lib/logger';
import VideoPlayer from '@/components/VideoPlayer';
import TimelineControls from '@/components/TimelineControls';
import EditingTools from '@/components/EditingTools';
import ClipSegments from '@/components/ClipSegments';
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
import useVideoChapters from '@/hooks/useVideoChapters';
const App = () => {
const {
@ -22,6 +21,7 @@ const App = () => {
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId,
hasUnsavedChanges,
historyPosition,
history,
@ -34,38 +34,15 @@ const App = () => {
handleUndo,
handleRedo,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
handleSegmentUpdate,
handleChapterSave,
handleSelectedSegmentChange,
isMobile,
videoInitialized,
setVideoInitialized,
isPlayingSegments,
handlePlaySegments
} = useVideoTrimmer();
// Function to play from the beginning
const playFromBeginning = () => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
handleMobileSafeSeek(0);
if (!isPlaying) {
handlePlay();
}
}
};
// Function to jump 15 seconds backward
const jumpBackward15 = () => {
const newTime = Math.max(0, currentTime - 15);
handleMobileSafeSeek(newTime);
};
// Function to jump 15 seconds forward
const jumpForward15 = () => {
const newTime = Math.min(duration, currentTime + 15);
handleMobileSafeSeek(newTime);
};
handlePlaySegments,
} = useVideoChapters();
const handlePlay = () => {
if (!videoRef.current) return;
@ -79,7 +56,7 @@ const App = () => {
return;
}
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
const currentPosition = Number(video.currentTime.toFixed(6));
// Find the next stopping point based on current position
let stopTime = duration;
@ -150,10 +127,10 @@ const App = () => {
const actualPosition = Number(video.currentTime.toFixed(6));
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
logger.debug("Position verification:", {
logger.debug('Position verification:', {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(actualPosition),
difference: difference
difference: difference,
});
// If we're not exactly at the target position, try one more time
@ -171,32 +148,28 @@ const App = () => {
setTimeout(setExactPosition, 50); // Final verification
// Remove our boundary checker
video.removeEventListener("timeupdate", checkBoundary);
video.removeEventListener('timeupdate', checkBoundary);
setIsPlaying(false);
// Log the final position for debugging
logger.debug("Stopped at position:", {
logger.debug('Stopped at position:', {
target: formatDetailedTime(stopTime),
actual: formatDetailedTime(video.currentTime),
type: currentSegment
? "segment end"
: nextSegment
? "next segment start"
: "end of video",
type: currentSegment ? 'segment end' : nextSegment ? 'next segment start' : 'end of video',
segment: currentSegment
? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
end: formatDetailedTime(currentSegment.endTime),
}
: null,
nextSegment: nextSegment
? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
end: formatDetailedTime(nextSegment.endTime),
}
: null
: null,
});
return;
@ -204,7 +177,7 @@ const App = () => {
};
// Start our boundary checker
video.addEventListener("timeupdate", checkBoundary);
video.addEventListener('timeupdate', checkBoundary);
// Start playing
video
@ -212,27 +185,27 @@ const App = () => {
.then(() => {
setIsPlaying(true);
setVideoInitialized(true);
logger.debug("Playback started:", {
logger.debug('Playback started:', {
from: formatDetailedTime(currentPosition),
to: formatDetailedTime(stopTime),
currentSegment: currentSegment
? {
id: currentSegment.id,
start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(currentSegment.endTime)
end: formatDetailedTime(currentSegment.endTime),
}
: "None",
: 'None',
nextSegment: nextSegment
? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
end: formatDetailedTime(nextSegment.endTime),
}
: "None"
: 'None',
});
})
.catch((err) => {
console.error("Error playing video:", err);
console.error('Error playing video:', err);
});
};
@ -277,14 +250,15 @@ const App = () => {
splitPoints={splitPoints}
zoomLevel={zoomLevel}
clipSegments={clipSegments}
selectedSegmentId={selectedSegmentId}
onSelectedSegmentChange={handleSelectedSegmentChange}
onSegmentUpdate={handleSegmentUpdate}
onChapterSave={handleChapterSave}
onTrimStartChange={handleTrimStartChange}
onTrimEndChange={handleTrimEndChange}
onZoomChange={handleZoomChange}
onSeek={handleMobileSafeSeek}
videoRef={videoRef}
onSave={handleSave}
onSaveACopy={handleSaveACopy}
onSaveSegments={handleSaveSegments}
hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying}
@ -294,7 +268,7 @@ const App = () => {
/>
{/* Clip Segments */}
<ClipSegments segments={clipSegments} />
<ClipSegments segments={clipSegments} selectedSegmentId={selectedSegmentId} />
</div>
</div>
);

View File

@ -7,13 +7,15 @@ export interface Segment {
startTime: number;
endTime: number;
thumbnail: string;
chapterTitle?: string;
}
interface ClipSegmentsProps {
segments: Segment[];
selectedSegmentId?: number | null;
}
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
@ -33,19 +35,31 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
// Get selected segment
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
<h3 className="clip-segments-title">Chapters</h3>
{sortedSegments.map((segment, index) => (
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)} ${selectedSegmentId === segment.id ? 'selected' : ''}`}
>
<div className="segment-content">
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">Segment {index + 1}</div>
<div className="segment-title">
{segment.chapterTitle ? (
<span className="chapter-title">{segment.chapterTitle}</span>
) : (
<span className="default-title">Chapter {index + 1}</span>
)}
</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
@ -74,7 +88,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
))}
{sortedSegments.length === 0 && (
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
<div className="empty-message">
No chapters created yet. Use the split button to create chapter segments.
</div>
)}
</div>
);

View File

@ -35,19 +35,20 @@ interface TimelineControlsProps {
splitPoints: number[];
zoomLevel: number;
clipSegments: Segment[];
selectedSegmentId?: number | null;
onSelectedSegmentChange?: (segmentId: number | null) => void;
onSegmentUpdate?: (segmentId: number, updates: Partial<Segment>) => void;
onChapterSave?: (chapters: { name: string; from: string; to: string }[]) => void;
onTrimStartChange: (time: number) => void;
onTrimEndChange: (time: number) => void;
onZoomChange: (level: number) => void;
onSeek: (time: number) => void;
videoRef: React.RefObject<HTMLVideoElement>;
onSave?: () => void;
onSaveACopy?: () => void;
onSaveSegments?: () => void;
hasUnsavedChanges?: boolean;
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onPlayPause: () => void; // Add this prop
onPlayPause: () => void;
isPlayingSegments?: boolean;
}
@ -109,14 +110,15 @@ const TimelineControls = ({
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId: externalSelectedSegmentId,
onSelectedSegmentChange,
onSegmentUpdate,
onChapterSave,
onTrimStartChange,
onTrimEndChange,
onZoomChange,
onSeek,
videoRef,
onSave,
onSaveACopy,
onSaveSegments,
hasUnsavedChanges = false,
isIOSUninitialized = false,
isPlaying,
@ -127,7 +129,17 @@ const TimelineControls = ({
const timelineRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
const rightHandleRef = useRef<HTMLDivElement>(null);
const [selectedSegmentId, setSelectedSegmentId] = useState<number | null>(null);
// Use external selectedSegmentId if provided, otherwise use internal state
const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState<number | null>(null);
const selectedSegmentId =
externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId;
const setSelectedSegmentId = (segmentId: number | null) => {
if (onSelectedSegmentChange) {
onSelectedSegmentChange(segmentId);
} else {
setInternalSelectedSegmentId(segmentId);
}
};
const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const [clickedTime, setClickedTime] = useState<number>(0);
@ -142,6 +154,49 @@ const TimelineControls = ({
// Reference for the scrollable container
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Chapter editor state
const [editingChapterTitle, setEditingChapterTitle] = useState<string>('');
const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false);
// Sort segments by startTime for chapter editor
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
// Update editing title when selected segment changes
useEffect(() => {
if (selectedSegment) {
setEditingChapterTitle(selectedSegment.chapterTitle || '');
} else {
setEditingChapterTitle('');
}
}, [selectedSegmentId, selectedSegment]);
// Handle chapter title change
const handleChapterTitleChange = (value: string) => {
setEditingChapterTitle(value);
setChapterHasUnsavedChanges(true);
// Update the segment immediately
if (selectedSegmentId && onSegmentUpdate) {
onSegmentUpdate(selectedSegmentId, { chapterTitle: value });
}
};
// Handle save chapters
const handleSaveChapters = () => {
if (!onChapterSave) return;
// Convert segments to chapter format
const chapters = sortedSegments.map((segment, index) => ({
name: segment.chapterTitle || `Chapter ${index + 1}`,
from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime),
}));
onChapterSave(chapters);
setChapterHasUnsavedChanges(false);
};
// Helper function for time adjustment buttons to maintain playback state
const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
e.stopPropagation();
@ -310,16 +365,14 @@ const TimelineControls = ({
};
// Modal states
const [showSaveModal, setShowSaveModal] = useState(false);
const [showSaveAsModal, setShowSaveAsModal] = useState(false);
const [showSaveSegmentsModal, setShowSaveSegmentsModal] = useState(false);
const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false);
const [showProcessingModal, setShowProcessingModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [showErrorModal, setShowErrorModal] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [redirectUrl, setRedirectUrl] = useState('');
const [saveType, setSaveType] = useState<'save' | 'copy' | 'segments'>('save');
const [saveType, setSaveType] = useState<'chapters'>('chapters');
// Calculate positions as percentages
const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0;
@ -328,218 +381,55 @@ const TimelineControls = ({
// No need for an extra effect here as we handle displayTime updates in the segment playback effect
// Save and API handlers
const handleSaveConfirm = async () => {
// Save Chapters handler
const handleSaveChaptersConfirm = async () => {
// Close confirmation modal and show processing modal
setShowSaveModal(false);
setShowSaveChaptersModal(false);
setShowProcessingModal(true);
setSaveType('save');
setSaveType('chapters');
try {
// Format segments data for API request
const segments = clipSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
// Format chapters data for API request
const chapters = clipSegments
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
.map((segment) => ({
name: segment.chapterTitle || `Chapter ${segment.id}`,
from: formatDetailedTime(segment.startTime),
to: formatDetailedTime(segment.endTime),
}));
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
const redirectURL = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectURL) || null;
// Log the request details for debugging
logger.debug('Save request:', {
mediaId,
segments,
saveAsCopy: false,
redirectURL,
});
const response = await trimVideo(mediaId, {
segments,
saveAsCopy: false,
});
// Log the response for debugging
logger.debug('Save response:', response);
// Hide processing modal
if (chapters.length === 0) {
setErrorMessage('No chapters with titles found');
setShowErrorModal(true);
setShowProcessingModal(false);
return;
}
// Check if response indicates success (200 OK)
if (response.status === 200) {
// For "Save", use the redirectURL from the window or response
const finalRedirectUrl = redirectURL || response.url_redirect;
logger.debug('Using redirect URL:', finalRedirectUrl);
// Call the onChapterSave function if provided
if (onChapterSave) {
await onChapterSave(chapters);
setShowProcessingModal(false);
setSuccessMessage('Chapters saved successfully!');
setRedirectUrl(finalRedirectUrl);
setSuccessMessage('Video saved successfully!');
// Set redirect URL to media page
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
if (mediaId) {
setRedirectUrl(`/view?m=${mediaId}`);
}
// Show success modal
setShowSuccessModal(true);
} else if (response.status === 400) {
// Set error message from response and show error modal
const errorMsg = response.error || 'An error occurred during processing';
logger.debug('Save error (400):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
} else {
// Handle other status codes as needed
logger.debug('Save error (unknown status):', response);
setErrorMessage('An unexpected error occurred');
setErrorMessage('Chapter save function not available');
setShowErrorModal(true);
setShowProcessingModal(false);
}
} catch (error) {
logger.error('Error processing video:', error);
logger.error('Error saving chapters:', error);
setShowProcessingModal(false);
// Set error message and show error modal
const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
logger.debug('Save error (exception):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
}
};
const handleSaveAsCopyConfirm = async () => {
// Close confirmation modal and show processing modal
setShowSaveAsModal(false);
setShowProcessingModal(true);
setSaveType('copy');
try {
// Format segments data for API request
const segments = clipSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
}));
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
const redirectUserMediaURL =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL) || null;
// Log the request details for debugging
logger.debug('Save as copy request:', {
mediaId,
segments,
saveAsCopy: true,
redirectUserMediaURL,
});
const response = await trimVideo(mediaId, {
segments,
saveAsCopy: true,
});
// Log the response for debugging
logger.debug('Save as copy response:', response);
// Hide processing modal
setShowProcessingModal(false);
// Check if response indicates success (200 OK)
if (response.status === 200) {
// For "Save As Copy", use the redirectUserMediaURL from the window
const finalRedirectUrl = redirectUserMediaURL || response.url_redirect;
logger.debug('Using redirect user media URL:', finalRedirectUrl);
setRedirectUrl(finalRedirectUrl);
setSuccessMessage('Video saved as a new copy!');
// Show success modal
setShowSuccessModal(true);
} else if (response.status === 400) {
// Set error message from response and show error modal
const errorMsg = response.error || 'An error occurred during processing';
logger.debug('Save as copy error (400):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
} else {
// Handle other status codes as needed
logger.debug('Save as copy error (unknown status):', response);
setErrorMessage('An unexpected error occurred');
setShowErrorModal(true);
}
} catch (error) {
logger.error('Error processing video:', error);
setShowProcessingModal(false);
// Set error message and show error modal
const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
logger.debug('Save as copy error (exception):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
}
};
const handleSaveSegmentsConfirm = async () => {
// Close confirmation modal and show processing modal
setShowSaveSegmentsModal(false);
setShowProcessingModal(true);
setSaveType('segments');
try {
// Format segments data for API request, with each segment saved as a separate file
const segments = clipSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
name: segment.name, // Include segment name for individual files
}));
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
const redirectUserMediaURL =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectUserMediaURL) || null;
// Log the request details for debugging
logger.debug('Save segments request:', {
mediaId,
segments,
saveAsCopy: true,
saveIndividualSegments: true,
redirectUserMediaURL,
});
const response = await trimVideo(mediaId, {
segments,
saveAsCopy: true,
saveIndividualSegments: true,
});
// Log the response for debugging
logger.debug('Save segments response:', response);
// Hide processing modal
setShowProcessingModal(false);
// Check if response indicates success (200 OK)
if (response.status === 200) {
// For "Save Segments", use the redirectUserMediaURL from the window
const finalRedirectUrl = redirectUserMediaURL || response.url_redirect;
logger.debug('Using redirect user media URL for segments:', finalRedirectUrl);
setRedirectUrl(finalRedirectUrl);
setSuccessMessage(`${segments.length} segments saved successfully!`);
// Show success modal
setShowSuccessModal(true);
} else if (response.status === 400) {
// Set error message from response and show error modal
const errorMsg = response.error || 'An error occurred during processing';
logger.debug('Save segments error (400):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
} else {
// Handle other status codes as needed
logger.debug('Save segments error (unknown status):', response);
setErrorMessage('An unexpected error occurred');
setShowErrorModal(true);
}
} catch (error) {
// Handle errors
logger.error('Error processing video segments:', error);
setShowProcessingModal(false);
// Set error message and show error modal
const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
logger.debug('Save segments error (exception):', errorMsg);
const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters';
logger.debug('Save chapters error (exception):', errorMsg);
setErrorMessage(errorMsg);
setShowErrorModal(true);
}
@ -2407,9 +2297,6 @@ const TimelineControls = ({
// Set redirect timeout
redirectTimeout = setTimeout(() => {
// Reset unsaved changes flag before navigating away
if (onSave) onSave();
// Redirect to the URL
logger.debug('Automatically redirecting to:', redirectUrl);
window.location.href = redirectUrl;
@ -2421,7 +2308,15 @@ const TimelineControls = ({
if (countdownInterval) clearInterval(countdownInterval);
if (redirectTimeout) clearTimeout(redirectTimeout);
};
}, [showSuccessModal, redirectUrl, onSave]);
}, [showSuccessModal, redirectUrl]);
// Effect to handle redirect after success modal is closed
useEffect(() => {
if (!showSuccessModal && redirectUrl) {
logger.debug('Redirecting to:', redirectUrl);
window.location.href = redirectUrl;
}
}, [redirectUrl, saveType, showSuccessModal]);
return (
<div className={`timeline-container-card ${isPlayingSegments ? 'segments-playback-mode' : ''}`}>
@ -2543,6 +2438,23 @@ const TimelineControls = ({
}
}}
>
{/* Chapter Editor for this segment */}
{selectedSegmentId && (
<div className="tooltip-chapter-editor">
<textarea
className="tooltip-chapter-input"
placeholder="Chapter Title"
value={editingChapterTitle}
onChange={(e) => handleChapterTitleChange(e.target.value)}
rows={2}
maxLength={200}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
/>
</div>
)}
{/* First row with time adjustment buttons */}
<div className="tooltip-row">
<button
@ -2600,7 +2512,6 @@ const TimelineControls = ({
+50ms
</button>
</div>
{/* Second row with action buttons */}
<div className="tooltip-row tooltip-actions">
<button
@ -4329,6 +4240,7 @@ const TimelineControls = ({
}
}}
/>
{/* Helper function to show tooltip at current position */}
{/* This is defined within the component to access state variables and functions */}
<div className="time-button-group">
@ -4512,99 +4424,44 @@ const TimelineControls = ({
)}
</div>
{/* Save Buttons Row */}
{/* Save Chapters Button */}
<div className="save-buttons-row">
{onSave && (
<button
onClick={() => setShowSaveModal(true)}
className="save-button"
data-tooltip="Save changes"
onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button"
data-tooltip="Save chapters"
disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0}
>
Save
Save Chapters
</button>
)}
{onSaveACopy && (
<button
onClick={() => setShowSaveAsModal(true)}
className="save-copy-button"
data-tooltip="Save as a new copy"
>
Save as Copy
</button>
)}
{onSaveSegments && (
<button
onClick={() => setShowSaveSegmentsModal(true)}
className="save-segments-button"
data-tooltip="Save segments as separate files"
>
Save Segments
</button>
)}
</div>
{/* Save Confirmation Modal */}
<Modal
isOpen={showSaveModal}
onClose={() => setShowSaveModal(false)}
title="Save Changes"
isOpen={showSaveChaptersModal}
onClose={() => setShowSaveChaptersModal(false)}
title="Save Chapters"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveModal(false)}
onClick={() => setShowSaveChaptersModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSave) onSave();
handleSaveConfirm();
}}
onClick={handleSaveChaptersConfirm}
>
Confirm Save
Save Chapters
</button>
</>
}
>
<p className="modal-message">
You're about to replace the original video with this trimmed version. This can't be undone.
</p>
</Modal>
{/* Save As Copy Modal */}
<Modal
isOpen={showSaveAsModal}
onClose={() => setShowSaveAsModal(false)}
title="Save As New Copy"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveAsModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSaveACopy) onSaveACopy();
handleSaveAsCopyConfirm();
}}
>
Confirm Save As Copy
</button>
</>
}
>
<p className="modal-message">
You're about to save a new copy with your edits. The original video will stay the same. Find
the new file in your My Media folder - named after the original file.
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.
</p>
</Modal>
@ -4616,38 +4473,6 @@ const TimelineControls = ({
<p className="modal-message text-center">Please wait while your video is being processed...</p>
</Modal>
{/* Save Segments Modal */}
<Modal
isOpen={showSaveSegmentsModal}
onClose={() => setShowSaveSegmentsModal(false)}
title="Save Segments"
actions={
<>
<button
className="modal-button modal-button-secondary"
onClick={() => setShowSaveSegmentsModal(false)}
>
Cancel
</button>
<button
className="modal-button modal-button-primary"
onClick={() => {
// Reset unsaved changes flag before saving
if (onSaveSegments) onSaveSegments();
handleSaveSegmentsConfirm();
}}
>
Save Segments
</button>
</>
}
>
<p className="modal-message">
You're about to save each segment as a separate video. Find the new files in your My Media
folder - named after the original file.
</p>
</Modal>
{/* Success Modal */}
<Modal
isOpen={showSuccessModal}
@ -4660,17 +4485,13 @@ const TimelineControls = ({
</p> */}
<p className="modal-message text-center redirect-message">
{saveType === 'segments'
? 'You will be redirected to your '
: 'You will be redirected to your '}
You will be redirected to your{' '}
<a href={redirectUrl} className="media-page-link" style={mediaPageLinkStyles}>
media page
</a>
{' in '}
<span className="countdown">10</span> seconds.{' '}
{saveType === 'segments'
? 'The new video(s) will soon be there.'
: 'Changes to the video might take a few minutes to be applied.'}
<span className="countdown">10</span> seconds. Your chapters have been saved
successfully.
</p>
</div>
</Modal>

View File

@ -13,7 +13,19 @@ interface EditorState {
action?: string;
}
const useVideoTrimmer = () => {
const useVideoChapters = () => {
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
const parseTimeToSeconds = (timeString: string): number => {
const parts = timeString.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0], 10) || 0;
const minutes = parseInt(parts[1], 10) || 0;
const seconds = parseFloat(parts[2]) || 0;
return hours * 3600 + minutes * 60 + seconds;
};
// Video element reference and state
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
@ -31,6 +43,9 @@ const useVideoTrimmer = () => {
// Clip segments state
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
// Selected segment state for chapter editing
const [selectedSegmentId, setSelectedSegmentId] = useState<number | null>(null);
// History state for undo/redo
const [history, setHistory] = useState<EditorState[]>([]);
const [historyPosition, setHistoryPosition] = useState(-1);
@ -94,12 +109,66 @@ const useVideoTrimmer = () => {
setDuration(video.duration);
setTrimEnd(video.duration);
// Generate placeholders and create initial segment
// Generate placeholders and create initial segments
const initializeEditor = async () => {
// Generate thumbnail for initial segment
let initialSegments: Segment[] = [];
// Check if we have existing chapters from the backend
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [
{
name: 'Chapter 1',
from: '00:00:00',
to: '00:00:03',
},
{
name: 'Chapter 2',
from: '00:00:03',
to: '00:00:06',
},
{
name: 'Chapter 3',
from: '00:00:09',
to: '00:00:12',
},
{
name: 'Chapter 4',
from: '00:00:15',
to: '00:00:18',
},
{
name: 'Chapter 5',
from: '00:00:21',
to: '00:00:24',
},
];
if (existingChapters.length > 0) {
// Create segments from existing chapters
for (let i = 0; i < existingChapters.length; i++) {
const chapter = existingChapters[i];
// Parse time strings to seconds
const startTime = parseTimeToSeconds(chapter.from);
const endTime = parseTimeToSeconds(chapter.to);
// Generate thumbnail for this segment
const segmentThumbnail = await generateThumbnail(video, (startTime + endTime) / 2);
const segment: Segment = {
id: i + 1,
name: `segment-${i + 1}`,
startTime: startTime,
endTime: endTime,
thumbnail: segmentThumbnail,
chapterTitle: chapter.name, // Set the chapter title from backend data
};
initialSegments.push(segment);
}
} else {
// Create a default segment that spans the entire video (fallback)
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
// Create an initial segment that spans the entire video
const initialSegment: Segment = {
id: 1,
name: 'segment',
@ -108,17 +177,20 @@ const useVideoTrimmer = () => {
thumbnail: segmentThumbnail,
};
// Initialize history state with the full-length segment
initialSegments = [initialSegment];
}
// Initialize history state with the segments
const initialState: EditorState = {
trimStart: 0,
trimEnd: video.duration,
splitPoints: [],
clipSegments: [initialSegment],
clipSegments: initialSegments,
};
setHistory([initialState]);
setHistoryPosition(0);
setClipSegments([initialSegment]);
setClipSegments(initialSegments);
// Generate timeline thumbnails
const count = 6;
@ -696,99 +768,81 @@ const useVideoTrimmer = () => {
setIsMuted(!isMuted);
};
// Handle save action
const handleSave = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving
const saveData = {
type: 'save',
segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
})),
// Handle updating a specific segment
const handleSegmentUpdate = (segmentId: number, updates: Partial<Segment>) => {
setClipSegments((prevSegments) =>
prevSegments.map((segment) => (segment.id === segmentId ? { ...segment, ...updates } : segment))
);
setHasUnsavedChanges(true);
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') {
console.debug('Saving data:', saveData);
// Handle saving chapters to database
const handleChapterSave = async (chapters: { name: string; from: string; to: string }[]) => {
try {
// Get media ID from window.MEDIA_DATA
const mediaId = (window as any).MEDIA_DATA?.mediaId;
if (!mediaId) {
console.error('No media ID found');
return;
}
// Convert chapters to backend expected format
const backendChapters = chapters.map((chapter) => ({
start: chapter.from,
title: chapter.name,
}));
// Create the API request body
const requestData = {
chapters: backendChapters,
};
console.log('Saving chapters:', requestData);
// Make API call to save chapters
const csrfToken = getCsrfToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
// TODO: Backend API is not ready yet
method: 'POST',
headers,
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error(`Failed to save chapters: ${response.status}`);
}
const result = await response.json();
console.log('Chapters saved successfully:', result);
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug('Changes saved - reset unsaved changes flag');
} catch (error) {
console.error('Error saving chapters:', error);
// You might want to show a user-friendly error message here
}
// Save to history with special "save" action to mark saved state
saveState('save');
// In a real implementation, this would make a POST request to save the data
// logger.debug("Save data:", saveData);
};
// Handle save a copy action
const handleSaveACopy = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving as a copy
const saveData = {
type: 'save_as_a_copy',
segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
})),
// Helper function to get CSRF token
const getCsrfToken = (): string => {
const name = 'csrftoken';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || '';
return '';
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') {
console.debug('Saving data as copy:', saveData);
}
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug('Changes saved as copy - reset unsaved changes flag');
}
// Save to history with special "save_copy" action to mark saved state
saveState('save_copy');
};
// Handle save segments individually action
const handleSaveSegments = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving individual segments
const saveData = {
type: 'save_segments',
segments: sortedSegments.map((segment) => ({
name: segment.name,
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime),
})),
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') {
console.debug('Saving data as segments:', saveData);
}
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
logger.debug('All segments saved individually - reset unsaved changes flag');
// Save to history with special "save_segments" action to mark saved state
saveState('save_segments');
// Handle selected segment change
const handleSelectedSegmentChange = (segmentId: number | null) => {
setSelectedSegmentId(segmentId);
};
// Handle seeking with mobile check
@ -928,6 +982,7 @@ const useVideoTrimmer = () => {
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId,
hasUnsavedChanges,
historyPosition,
history,
@ -941,13 +996,13 @@ const useVideoTrimmer = () => {
handleRedo,
handlePlaySegments,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
handleSegmentUpdate,
handleChapterSave,
handleSelectedSegmentChange,
isMobile,
videoInitialized,
setVideoInitialized,
};
};
export default useVideoTrimmer;
export default useVideoChapters;

View File

@ -71,13 +71,125 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.clip-segments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin: 0;
}
.save-chapters-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
&.has-changes {
background-color: #10b981;
animation: pulse-green 2s infinite;
}
&.has-changes:hover {
background-color: #059669;
}
svg {
width: 1rem;
height: 1rem;
}
}
@keyframes pulse-green {
0%,
100% {
background-color: #10b981;
}
50% {
background-color: #34d399;
}
}
.chapter-editor {
background-color: #f8fafc;
border: 2px solid #3b82f6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s ease;
}
.chapter-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.chapter-editor-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.chapter-editor-segment {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.chapter-title-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
resize: vertical;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.chapter-editor-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
.segment-item {
display: flex;
align-items: center;
@ -86,11 +198,17 @@
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: box-shadow 0.2s ease;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background-color: rgba(59, 130, 246, 0.05);
}
}
.segment-content {
@ -119,6 +237,16 @@
color: black;
}
.chapter-title {
color: #1f2937;
font-weight: 600;
}
.default-title {
color: #6b7280;
font-style: italic;
}
.segment-time {
font-size: 0.75rem;
color: black;
@ -193,4 +321,28 @@
.segment-color-8 {
background-color: rgba(250, 204, 21, 0.15);
}
/* Responsive styles */
@media (max-width: 768px) {
.clip-segments-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.save-chapters-button {
justify-content: center;
}
.chapter-editor-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.chapter-editor-segment {
align-self: stretch;
text-align: center;
}
}
}

View File

@ -315,10 +315,14 @@
min-width: 150px;
text-align: center;
pointer-events: auto;
top: -100px !important;
top: -105px !important;
transform: translateY(-10px);
}
.segment-tooltip {
top: -165px !important;
}
.segment-tooltip:after,
.empty-space-tooltip:after {
content: "";
@ -872,3 +876,168 @@
margin-right: 0.5rem;
color: #3b82f6;
}
/* Chapter Editor Styles */
.chapter-editor {
background-color: #f8fafc;
border: 2px solid #3b82f6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s ease;
}
.chapter-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
gap: 1rem;
}
.chapter-editor-title-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.chapter-editor-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.chapter-editor-segment {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: inline-block;
}
.save-chapters-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
}
.save-chapters-button:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
.save-chapters-button.has-changes {
background-color: #10b981;
animation: pulse-green-chapters 2s infinite;
}
.save-chapters-button.has-changes:hover {
background-color: #059669;
}
.save-chapters-button svg {
width: 1rem;
height: 1rem;
}
@keyframes pulse-green-chapters {
0%,
100% {
background-color: #10b981;
}
50% {
background-color: #34d399;
}
}
.chapter-title-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
resize: vertical;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.chapter-title-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.chapter-title-input::placeholder {
color: #9ca3af;
}
.chapter-editor-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
/* Chapter Editor Responsive styles */
@media (max-width: 768px) {
.chapter-editor-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.save-chapters-button {
justify-content: center;
align-self: stretch;
}
.chapter-editor-segment {
text-align: center;
}
}
/* Tooltip Chapter Editor Styles */
.tooltip-chapter-editor {
background-color: rgba(255, 255, 255, 0.95);
border-radius: 0.375rem;
pointer-events: auto; /* Ensure it can receive clicks */
}
.tooltip-chapter-input {
width: 100%;
padding: 0.5rem;
border: 2px solid #ccc;
border-radius: 0.25rem;
background-color: white;
color: black;
font-size: 0.75rem;
resize: none;
outline: none;
box-sizing: border-box;
max-height: 70px !important;
}
.tooltip-chapter-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.tooltip-chapter-input::placeholder {
color: #999;
}

View File

@ -30,12 +30,10 @@ export default defineConfig({
},
rollupOptions: {
output: {
// Ensure CSS file has a predictable name
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
return assetInfo.name;
},
// Add this to ensure the final bundle exposes React correctly
globals: {
react: 'React',
'react-dom': 'ReactDOM',

View File

@ -47,7 +47,7 @@ interface TimelineControlsProps {
isIOSUninitialized?: boolean;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
onPlayPause: () => void; // Add this prop
onPlayPause: () => void;
isPlayingSegments?: boolean;
}