mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-07 07:58:53 -05:00
feat: Chapter editor main functionality and styling
This commit is contained in:
parent
79de619be4
commit
8df5ea880c
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -47,7 +47,7 @@ interface TimelineControlsProps {
|
||||
isIOSUninitialized?: boolean;
|
||||
isPlaying: boolean;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
onPlayPause: () => void; // Add this prop
|
||||
onPlayPause: () => void;
|
||||
isPlayingSegments?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user