mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-05 23:18:53 -05:00
feat: Auto-save functionality for video editor
This commit is contained in:
parent
c54ff2e48b
commit
94c07d4531
@ -1,5 +1,6 @@
|
||||
import "../styles/EditingTools.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
@ -42,7 +43,7 @@ const EditingTools = ({
|
||||
const handlePlay = () => {
|
||||
// Ensure lastSeekedPosition is used when play is clicked
|
||||
if (typeof window !== "undefined") {
|
||||
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
||||
logger.debug("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { formatTime, formatDetailedTime } from "../lib/timeUtils";
|
||||
import { generateThumbnail, generateSolidColor } from "../lib/videoUtils";
|
||||
import { Segment } from "./ClipSegments";
|
||||
import Modal from "./Modal";
|
||||
import { trimVideo } from "../services/videoApi";
|
||||
import { trimVideo, autoSaveVideo, fetchAutoSavedSegments } from "../services/videoApi";
|
||||
import logger from "../lib/logger";
|
||||
import "../styles/TimelineControls.css";
|
||||
import "../styles/TwoRowTooltip.css";
|
||||
@ -142,6 +142,96 @@ const TimelineControls = ({
|
||||
// Reference for the scrollable container
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-save related state
|
||||
const [lastAutoSaveTime, setLastAutoSaveTime] = useState<string>("");
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clipSegmentsRef = useRef(clipSegments);
|
||||
|
||||
// Keep clipSegmentsRef updated
|
||||
useEffect(() => {
|
||||
clipSegmentsRef.current = clipSegments;
|
||||
}, [clipSegments]);
|
||||
|
||||
// Auto-save function
|
||||
const performAutoSave = useCallback(async () => {
|
||||
try {
|
||||
setIsAutoSaving(true);
|
||||
|
||||
// Format segments data for API request - use ref to get latest segments
|
||||
const segments = clipSegmentsRef.current.map((segment) => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime),
|
||||
name: segment.name
|
||||
}));
|
||||
|
||||
logger.debug("segments", segments);
|
||||
|
||||
const mediaId =
|
||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.mediaId) || null;
|
||||
// For testing, use '1234' if no mediaId is available
|
||||
const finalMediaId = mediaId || "1234";
|
||||
|
||||
logger.debug("mediaId", finalMediaId);
|
||||
|
||||
if (!finalMediaId || segments.length === 0) {
|
||||
logger.debug("No mediaId or segments, skipping auto-save");
|
||||
setIsAutoSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Auto-saving segments:", { mediaId: finalMediaId, segments });
|
||||
|
||||
const response = await autoSaveVideo(finalMediaId, { segments });
|
||||
|
||||
if (response.success) {
|
||||
logger.debug("Auto-save successful");
|
||||
// Format the timestamp for display
|
||||
const date = new Date(response.timestamp);
|
||||
const formattedTime = date
|
||||
.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
.replace(",", "");
|
||||
|
||||
setLastAutoSaveTime(formattedTime);
|
||||
logger.debug("Auto-save successful:", formattedTime);
|
||||
} else {
|
||||
logger.error("Auto-save failed:", response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Auto-save error:", error);
|
||||
} finally {
|
||||
setIsAutoSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Schedule auto-save with debounce
|
||||
const scheduleAutoSave = useCallback(() => {
|
||||
// Clear any existing timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
logger.debug("Cleared existing auto-save timer");
|
||||
}
|
||||
|
||||
logger.debug("Scheduling new auto-save in 1 second...");
|
||||
|
||||
// Schedule new auto-save after 1 second of inactivity
|
||||
const timerId = setTimeout(() => {
|
||||
logger.debug("Auto-save timer fired! Calling performAutoSave...");
|
||||
performAutoSave();
|
||||
}, 1000);
|
||||
|
||||
autoSaveTimerRef.current = timerId;
|
||||
logger.debug("Timer ID set:", timerId);
|
||||
}, [performAutoSave]);
|
||||
|
||||
// Helper function for time adjustment buttons to maintain playback state
|
||||
const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@ -949,6 +1039,189 @@ const TimelineControls = ({
|
||||
};
|
||||
}, [isZoomDropdownOpen]);
|
||||
|
||||
// Listen for segment updates and trigger auto-save
|
||||
useEffect(() => {
|
||||
const handleSegmentUpdate = (event: CustomEvent) => {
|
||||
const { recordHistory, fromAutoSave } = event.detail;
|
||||
logger.debug(
|
||||
"handleSegmentUpdate called, recordHistory:",
|
||||
recordHistory,
|
||||
"fromAutoSave:",
|
||||
fromAutoSave
|
||||
);
|
||||
// Only auto-save when history is recorded and not loading from auto-save
|
||||
if (recordHistory && !fromAutoSave) {
|
||||
logger.debug("Calling scheduleAutoSave from handleSegmentUpdate");
|
||||
scheduleAutoSave();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSegmentDragEnd = () => {
|
||||
// Trigger auto-save when drag operations end
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const handleTrimUpdate = (event: CustomEvent) => {
|
||||
const { recordHistory } = event.detail;
|
||||
// Only auto-save when history is recorded (i.e., after trim operations complete)
|
||||
if (recordHistory) {
|
||||
scheduleAutoSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-segments", handleSegmentUpdate as EventListener);
|
||||
document.addEventListener("segment-drag-end", handleSegmentDragEnd);
|
||||
document.addEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
document.addEventListener("delete-segment", scheduleAutoSave);
|
||||
document.addEventListener("split-segment", scheduleAutoSave);
|
||||
|
||||
return () => {
|
||||
logger.debug("Cleaning up auto-save event listeners...");
|
||||
document.removeEventListener("update-segments", handleSegmentUpdate as EventListener);
|
||||
document.removeEventListener("segment-drag-end", handleSegmentDragEnd);
|
||||
document.removeEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
document.removeEventListener("delete-segment", scheduleAutoSave);
|
||||
document.removeEventListener("split-segment", scheduleAutoSave);
|
||||
|
||||
// Clear any pending auto-save timer
|
||||
if (autoSaveTimerRef.current) {
|
||||
logger.debug("Clearing auto-save timer in cleanup:", autoSaveTimerRef.current);
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [scheduleAutoSave]);
|
||||
|
||||
// Perform initial auto-save when component mounts with segments
|
||||
useEffect(() => {
|
||||
if (clipSegments.length > 0 && !lastAutoSaveTime) {
|
||||
// Perform initial auto-save after a short delay
|
||||
setTimeout(() => {
|
||||
performAutoSave();
|
||||
}, 500);
|
||||
}
|
||||
}, [lastAutoSaveTime, performAutoSave]);
|
||||
|
||||
// Load saved segments from MEDIA_DATA on component mount
|
||||
useEffect(() => {
|
||||
const loadSavedSegments = () => {
|
||||
// Get savedSegments directly from window.MEDIA_DATA
|
||||
let savedData =
|
||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.savedSegments) || null;
|
||||
|
||||
// If no saved segments, use default segments
|
||||
if (!savedData) {
|
||||
logger.debug("No saved segments found in MEDIA_DATA, using default segments");
|
||||
savedData = {
|
||||
segments: [
|
||||
{
|
||||
startTime: "00:00:20.130",
|
||||
endTime: "00:00:51.442",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:00:58.152",
|
||||
endTime: "00:01:20.518",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:01:20.518",
|
||||
endTime: "00:01:45.121",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:02:14.757",
|
||||
endTime: "00:03:25.769",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:04:26.158",
|
||||
endTime: "00:05:24.870",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:06:17.430",
|
||||
endTime: "00:07:31.798",
|
||||
name: "segment"
|
||||
},
|
||||
{
|
||||
startTime: "00:07:42.981",
|
||||
endTime: "00:10:08.362",
|
||||
name: "segment"
|
||||
}
|
||||
],
|
||||
updated_at: "2025-06-24 14:59:14"
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("Loading saved segments:", savedData);
|
||||
|
||||
try {
|
||||
if (savedData && savedData.segments && savedData.segments.length > 0) {
|
||||
logger.debug("Found saved segments:", savedData);
|
||||
|
||||
// Convert the saved segments to the format expected by the component
|
||||
const convertedSegments: Segment[] = savedData.segments.map(
|
||||
(seg: any, index: number) => ({
|
||||
id: Date.now() + index, // Generate unique IDs
|
||||
name: seg.name || `Segment ${index + 1}`,
|
||||
startTime: parseTimeString(seg.startTime),
|
||||
endTime: parseTimeString(seg.endTime),
|
||||
thumbnail: ""
|
||||
})
|
||||
);
|
||||
|
||||
// Dispatch event to update segments
|
||||
const updateEvent = new CustomEvent("update-segments", {
|
||||
detail: {
|
||||
segments: convertedSegments,
|
||||
recordHistory: false, // Don't record loading saved segments in history
|
||||
fromAutoSave: true
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(updateEvent);
|
||||
|
||||
// Update the last auto-save time
|
||||
if (savedData.updated_at) {
|
||||
const date = new Date(savedData.updated_at);
|
||||
const formattedTime = date
|
||||
.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
.replace(",", "");
|
||||
setLastAutoSaveTime(formattedTime);
|
||||
}
|
||||
} else {
|
||||
logger.debug("No saved segments found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading saved segments:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to parse time string "HH:MM:SS.mmm" to seconds
|
||||
const parseTimeString = (timeStr: string): number => {
|
||||
const parts = timeStr.split(":");
|
||||
if (parts.length !== 3) return 0;
|
||||
|
||||
const hours = parseInt(parts[0]) || 0;
|
||||
const minutes = parseInt(parts[1]) || 0;
|
||||
const secondsParts = parts[2].split(".");
|
||||
const seconds = parseInt(secondsParts[0]) || 0;
|
||||
const milliseconds = parseInt(secondsParts[1]) || 0;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
|
||||
};
|
||||
|
||||
// Load saved segments after a short delay to ensure component is ready
|
||||
setTimeout(loadSavedSegments, 100);
|
||||
}, []); // Run only once on mount
|
||||
|
||||
// Global click handler to close tooltips when clicking outside
|
||||
useEffect(() => {
|
||||
// Remove the global click handler that closes tooltips
|
||||
@ -3179,6 +3452,7 @@ const TimelineControls = ({
|
||||
action: "create_segment"
|
||||
}
|
||||
});
|
||||
logger.debug("Dispatching update-segments event for new segment creation");
|
||||
document.dispatchEvent(updateEvent);
|
||||
|
||||
// Close empty space tooltip
|
||||
@ -4540,6 +4814,41 @@ const TimelineControls = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto saved time */}
|
||||
<div
|
||||
className="auto-saved-time"
|
||||
style={{
|
||||
color: isAutoSaving ? "#1976d2" : "gray",
|
||||
fontSize: "12px",
|
||||
marginLeft: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "5px"
|
||||
}}
|
||||
>
|
||||
{isAutoSaving ? (
|
||||
<>
|
||||
<span
|
||||
className="auto-save-spinner"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
border: "2px solid #f3f3f3",
|
||||
borderTop: "2px solid #1976d2",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite"
|
||||
}}
|
||||
></span>
|
||||
Auto saving...
|
||||
</>
|
||||
) : lastAutoSaveTime ? (
|
||||
`Auto saved: ${lastAutoSaveTime}`
|
||||
) : (
|
||||
"Not saved yet"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Buttons Row */}
|
||||
<div className="save-buttons-row">
|
||||
{onSave && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// API service for video trimming operations
|
||||
|
||||
import logger from "../lib/logger";
|
||||
interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
@ -20,8 +20,98 @@ interface TrimVideoResponse {
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// For now, we'll use a mock API that returns a promise
|
||||
// This can be replaced with actual API calls later
|
||||
// Auto-save interface
|
||||
interface AutoSaveRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface AutoSaveResponse {
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
status?: string;
|
||||
media_id?: string;
|
||||
segments?: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name: string;
|
||||
}[];
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Auto-save API function
|
||||
export const autoSaveVideo = async (
|
||||
mediaId: string,
|
||||
data: AutoSaveRequest
|
||||
): Promise<AutoSaveResponse> => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/auto_save`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
logger.debug("response", response);
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status
|
||||
if (response.status === 404) {
|
||||
// If endpoint not ready (404), return mock success response
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
success: true,
|
||||
timestamp: timestamp
|
||||
};
|
||||
} else {
|
||||
// Handle other error responses
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: errorData.error || "Auto-save failed"
|
||||
};
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: "Auto-save failed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
|
||||
// Check if the response has the expected format
|
||||
if (jsonResponse.status === "success") {
|
||||
return {
|
||||
success: true,
|
||||
timestamp: jsonResponse.updated_at || new Date().toISOString(),
|
||||
...jsonResponse
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: jsonResponse.error || "Auto-save failed"
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// For any fetch errors, return mock success response
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
success: true,
|
||||
timestamp: timestamp
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const trimVideo = async (
|
||||
mediaId: string,
|
||||
data: TrimVideoRequest
|
||||
|
||||
@ -719,6 +719,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.auto-save-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user