mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-10-06 22:32:41 +07:00
321287d009
In Preview mode, you can jump in timeline to review the edits, without having to listen to the full video.
1130 lines
38 KiB
TypeScript
1130 lines
38 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { generateThumbnail } from "@/lib/videoUtils";
|
|
import { formatDetailedTime } from "@/lib/timeUtils";
|
|
import logger from "@/lib/logger";
|
|
import type { Segment } from "@/components/ClipSegments";
|
|
|
|
// Represents a state of the editor for undo/redo
|
|
interface EditorState {
|
|
trimStart: number;
|
|
trimEnd: number;
|
|
splitPoints: number[];
|
|
clipSegments: Segment[];
|
|
action?: string;
|
|
}
|
|
|
|
const useVideoTrimmer = () => {
|
|
// Video element reference and state
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
|
|
// Preview mode state for playing only segments
|
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
const [previewSegmentIndex, setPreviewSegmentIndex] = useState(0);
|
|
|
|
// Timeline state
|
|
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
|
const [trimStart, setTrimStart] = useState(0);
|
|
const [trimEnd, setTrimEnd] = useState(0);
|
|
const [splitPoints, setSplitPoints] = useState<number[]>([]);
|
|
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
|
|
|
|
// Clip segments state
|
|
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
|
|
|
// History state for undo/redo
|
|
const [history, setHistory] = useState<EditorState[]>([]);
|
|
const [historyPosition, setHistoryPosition] = useState(-1);
|
|
|
|
// Track unsaved changes
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
|
|
// State for playing segments
|
|
const [isPlayingSegments, setIsPlayingSegments] = useState(false);
|
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
|
|
|
|
// Monitor for history changes
|
|
useEffect(() => {
|
|
if (history.length > 0) {
|
|
// For debugging - moved to console.debug
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`);
|
|
// Log actions in history to help debug undo/redo
|
|
const actions = history.map((state, idx) =>
|
|
`${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})`
|
|
);
|
|
console.debug('History actions:', actions);
|
|
}
|
|
|
|
// If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
|
|
const lastAction = history[historyPosition]?.action || '';
|
|
if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') {
|
|
setHasUnsavedChanges(true);
|
|
}
|
|
}
|
|
}, [history, historyPosition]);
|
|
|
|
// Set up page unload warning
|
|
useEffect(() => {
|
|
// Event handler for beforeunload
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
if (hasUnsavedChanges) {
|
|
// Standard way of showing a confirmation dialog before leaving
|
|
const message = 'Your edits will get lost if you leave the page. Do you want to continue?';
|
|
e.preventDefault();
|
|
e.returnValue = message; // Chrome requires returnValue to be set
|
|
return message; // For other browsers
|
|
}
|
|
};
|
|
|
|
// Add event listener
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
// Clean up
|
|
return () => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
};
|
|
}, [hasUnsavedChanges]);
|
|
|
|
// Initialize video event listeners
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setDuration(video.duration);
|
|
setTrimEnd(video.duration);
|
|
|
|
// Generate placeholders and create initial segment
|
|
const initializeEditor = async () => {
|
|
// Generate thumbnail for initial segment
|
|
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
|
|
|
|
// Create an initial segment that spans the entire video
|
|
const initialSegment: Segment = {
|
|
id: 1,
|
|
name: "segment",
|
|
startTime: 0,
|
|
endTime: video.duration,
|
|
thumbnail: segmentThumbnail
|
|
};
|
|
|
|
// Initialize history state with the full-length segment
|
|
const initialState: EditorState = {
|
|
trimStart: 0,
|
|
trimEnd: video.duration,
|
|
splitPoints: [],
|
|
clipSegments: [initialSegment]
|
|
};
|
|
|
|
setHistory([initialState]);
|
|
setHistoryPosition(0);
|
|
setClipSegments([initialSegment]);
|
|
|
|
// Generate timeline thumbnails
|
|
const count = 6;
|
|
const interval = video.duration / count;
|
|
const placeholders: string[] = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const time = interval * i + interval / 2;
|
|
const thumbnail = await generateThumbnail(video, time);
|
|
placeholders.push(thumbnail);
|
|
}
|
|
|
|
setThumbnails(placeholders);
|
|
};
|
|
|
|
initializeEditor();
|
|
};
|
|
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(video.currentTime);
|
|
};
|
|
|
|
const handlePlay = () => {
|
|
// Only update isPlaying if we're not in preview mode
|
|
if (!isPreviewMode) {
|
|
setIsPlaying(true);
|
|
setVideoInitialized(true);
|
|
}
|
|
};
|
|
|
|
const handlePause = () => {
|
|
// Only update isPlaying if we're not in preview mode
|
|
if (!isPreviewMode) {
|
|
setIsPlaying(false);
|
|
}
|
|
};
|
|
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
video.currentTime = trimStart;
|
|
};
|
|
|
|
// Add event listeners
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
|
video.addEventListener('play', handlePlay);
|
|
video.addEventListener('pause', handlePause);
|
|
video.addEventListener('ended', handleEnded);
|
|
|
|
return () => {
|
|
// Remove event listeners
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
video.removeEventListener('play', handlePlay);
|
|
video.removeEventListener('pause', handlePause);
|
|
video.removeEventListener('ended', handleEnded);
|
|
};
|
|
}, [isPreviewMode]);
|
|
|
|
// Play/pause video
|
|
const playPauseVideo = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (isPlaying) {
|
|
video.pause();
|
|
} else {
|
|
// iOS Safari fix: Use the last seeked position if available
|
|
if (!isPlaying && typeof window !== 'undefined' && window.lastSeekedPosition > 0) {
|
|
// Only apply this if the video is not at the same position already
|
|
// This avoids unnecessary seeking which might cause playback issues
|
|
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
|
video.currentTime = window.lastSeekedPosition;
|
|
}
|
|
}
|
|
// If at the end of the trim range, reset to the beginning
|
|
else if (video.currentTime >= trimEnd) {
|
|
video.currentTime = trimStart;
|
|
}
|
|
|
|
video.play()
|
|
.then(() => {
|
|
// Play started successfully
|
|
// Reset the last seeked position after successfully starting playback
|
|
if (typeof window !== 'undefined') {
|
|
window.lastSeekedPosition = 0;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Error starting playback:", err);
|
|
setIsPlaying(false); // Reset state if play failed
|
|
});
|
|
}
|
|
};
|
|
|
|
// Seek to a specific time
|
|
const seekVideo = (time: number) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
// Track if the video was playing before seeking
|
|
const wasPlaying = !video.paused;
|
|
|
|
// Store current preview mode state to preserve it
|
|
const wasInPreviewMode = isPreviewMode;
|
|
|
|
// Update the video position
|
|
video.currentTime = time;
|
|
setCurrentTime(time);
|
|
|
|
// Store the position in a global state accessible to iOS Safari
|
|
// This ensures when play is pressed later, it remembers the position
|
|
if (typeof window !== 'undefined') {
|
|
window.lastSeekedPosition = time;
|
|
}
|
|
|
|
// Find segment at this position for preview mode playback
|
|
if (wasInPreviewMode) {
|
|
const segmentAtPosition = clipSegments.find(
|
|
seg => time >= seg.startTime && time <= seg.endTime
|
|
);
|
|
|
|
if (segmentAtPosition) {
|
|
// Update the active segment index in preview mode
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
const newSegmentIndex = orderedSegments.findIndex(seg => seg.id === segmentAtPosition.id);
|
|
if (newSegmentIndex !== -1) {
|
|
setPreviewSegmentIndex(newSegmentIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resume playback in two scenarios:
|
|
// 1. If it was playing before (regular mode)
|
|
// 2. If we're in preview mode (regardless of previous state)
|
|
if (wasPlaying || wasInPreviewMode) {
|
|
// Ensure preview mode stays on if it was on before
|
|
if (wasInPreviewMode) {
|
|
setIsPreviewMode(true);
|
|
}
|
|
|
|
// Play immediately without delay
|
|
video.play()
|
|
.then(() => {
|
|
setIsPlaying(true); // Update state to reflect we're playing
|
|
// "Resumed playback after seeking in " + (wasInPreviewMode ? "preview" : "regular") + " mode"
|
|
})
|
|
.catch(err => {
|
|
console.error("Error resuming playback:", err);
|
|
setIsPlaying(false);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Save the current state to history with a debounce buffer
|
|
// This helps prevent multiple rapid saves for small adjustments
|
|
const saveState = (action?: string) => {
|
|
// Deep clone to ensure state is captured correctly
|
|
const newState: EditorState = {
|
|
trimStart,
|
|
trimEnd,
|
|
splitPoints: [...splitPoints],
|
|
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
|
|
action: action || 'manual_save' // Track the action that triggered this save
|
|
};
|
|
|
|
// Check if state is significantly different from last saved state
|
|
const lastState = history[historyPosition];
|
|
|
|
// Helper function to compare segments deeply
|
|
const haveSegmentsChanged = () => {
|
|
if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
|
|
return true; // Different length means significant change
|
|
}
|
|
|
|
// Compare each segment's start and end times
|
|
for (let i = 0; i < newState.clipSegments.length; i++) {
|
|
const oldSeg = lastState.clipSegments[i];
|
|
const newSeg = newState.clipSegments[i];
|
|
|
|
if (!oldSeg || !newSeg) return true;
|
|
|
|
// Check if any time values changed by more than 0.001 seconds (1ms)
|
|
if (Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
|
|
Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false; // No significant changes found
|
|
};
|
|
|
|
const isSignificantChange = !lastState ||
|
|
lastState.trimStart !== newState.trimStart ||
|
|
lastState.trimEnd !== newState.trimEnd ||
|
|
lastState.splitPoints.length !== newState.splitPoints.length ||
|
|
haveSegmentsChanged();
|
|
|
|
// Additionally, check if there's an explicit action from a UI event
|
|
const hasExplicitActionFlag = newState.action !== undefined;
|
|
|
|
// Only proceed if this is a significant change or if explicitly requested
|
|
if (isSignificantChange || hasExplicitActionFlag) {
|
|
// Get the current position to avoid closure issues
|
|
const currentPosition = historyPosition;
|
|
|
|
// Use functional updates to ensure we're working with the latest state
|
|
setHistory(prevHistory => {
|
|
// If we're not at the end of history, truncate
|
|
if (currentPosition < prevHistory.length - 1) {
|
|
const newHistory = prevHistory.slice(0, currentPosition + 1);
|
|
return [...newHistory, newState];
|
|
} else {
|
|
// Just append to current history
|
|
return [...prevHistory, newState];
|
|
}
|
|
});
|
|
|
|
// Update position using functional update
|
|
setHistoryPosition(prev => {
|
|
const newPosition = prev + 1;
|
|
// "Saved state to history position", newPosition)
|
|
return newPosition;
|
|
});
|
|
} else {
|
|
// logger.debug("Skipped non-significant state save");
|
|
}
|
|
};
|
|
|
|
// Listen for trim handle update events
|
|
useEffect(() => {
|
|
const handleTrimUpdate = (e: CustomEvent) => {
|
|
if (e.detail) {
|
|
const { time, isStart, recordHistory, action } = e.detail;
|
|
|
|
if (isStart) {
|
|
setTrimStart(time);
|
|
} else {
|
|
setTrimEnd(time);
|
|
}
|
|
|
|
// Only record in history if explicitly requested
|
|
if (recordHistory) {
|
|
// Use a small timeout to ensure the state is updated
|
|
setTimeout(() => {
|
|
saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end'));
|
|
}, 10);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('update-trim', handleTrimUpdate as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
// Listen for segment update events and split-at-time events
|
|
useEffect(() => {
|
|
const handleUpdateSegments = (e: CustomEvent) => {
|
|
if (e.detail && e.detail.segments) {
|
|
// Check if this is a significant change that should be recorded in history
|
|
// Default to true to ensure all segment changes are recorded
|
|
const isSignificantChange = e.detail.recordHistory !== false;
|
|
// Get the action type if provided
|
|
const actionType = e.detail.action || 'update_segments';
|
|
|
|
// Log the update details
|
|
logger.debug(`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`);
|
|
|
|
// Update segment state immediately for UI feedback
|
|
setClipSegments(e.detail.segments);
|
|
|
|
// Always save state to history for non-intermediate actions
|
|
if (isSignificantChange) {
|
|
// A slight delay helps avoid race conditions but we need to
|
|
// ensure we capture the state properly
|
|
setTimeout(() => {
|
|
// Deep clone to ensure state is captured correctly
|
|
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
|
|
|
// Create a complete state snapshot
|
|
const stateWithAction: EditorState = {
|
|
trimStart,
|
|
trimEnd,
|
|
splitPoints: [...splitPoints],
|
|
clipSegments: segmentsClone,
|
|
action: actionType // Store the action type in the state
|
|
};
|
|
|
|
// Get the current history position to ensure we're using the latest value
|
|
const currentHistoryPosition = historyPosition;
|
|
|
|
// Update history with the functional pattern to avoid stale closure issues
|
|
setHistory(prevHistory => {
|
|
// If we're not at the end of the history, truncate
|
|
if (currentHistoryPosition < prevHistory.length - 1) {
|
|
const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
|
|
return [...newHistory, stateWithAction];
|
|
} else {
|
|
// Just append to current history
|
|
return [...prevHistory, stateWithAction];
|
|
}
|
|
});
|
|
|
|
// Ensure the historyPosition is updated to the correct position
|
|
setHistoryPosition(prev => {
|
|
const newPosition = prev + 1;
|
|
logger.debug(`Saved state with action: ${actionType} to history position ${newPosition}`);
|
|
return newPosition;
|
|
});
|
|
}, 20); // Slightly increased delay to ensure state updates are complete
|
|
} else {
|
|
logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSplitSegment = async (e: Event) => {
|
|
const customEvent = e as CustomEvent;
|
|
if (customEvent.detail &&
|
|
typeof customEvent.detail.time === 'number' &&
|
|
typeof customEvent.detail.segmentId === 'number') {
|
|
|
|
// Get the time and segment ID from the event
|
|
const timeToSplit = customEvent.detail.time;
|
|
const segmentId = customEvent.detail.segmentId;
|
|
|
|
// Move the current time to the split position
|
|
seekVideo(timeToSplit);
|
|
|
|
// Find the segment to split
|
|
const segmentToSplit = clipSegments.find(seg => seg.id === segmentId);
|
|
if (!segmentToSplit) return;
|
|
|
|
// Make sure the split point is within the segment
|
|
if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
|
|
return; // Can't split outside segment boundaries
|
|
}
|
|
|
|
// Create two new segments from the split
|
|
const newSegments = [...clipSegments];
|
|
|
|
// Remove the original segment
|
|
const segmentIndex = newSegments.findIndex(seg => seg.id === segmentId);
|
|
if (segmentIndex === -1) return;
|
|
|
|
newSegments.splice(segmentIndex, 1);
|
|
|
|
// Create first half of the split segment - no thumbnail needed
|
|
const firstHalf: Segment = {
|
|
id: Date.now(),
|
|
name: `${segmentToSplit.name}-A`,
|
|
startTime: segmentToSplit.startTime,
|
|
endTime: timeToSplit,
|
|
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
|
};
|
|
|
|
// Create second half of the split segment - no thumbnail needed
|
|
const secondHalf: Segment = {
|
|
id: Date.now() + 1,
|
|
name: `${segmentToSplit.name}-B`,
|
|
startTime: timeToSplit,
|
|
endTime: segmentToSplit.endTime,
|
|
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
|
};
|
|
|
|
// Add the new segments
|
|
newSegments.push(firstHalf, secondHalf);
|
|
|
|
// Sort segments by start time
|
|
newSegments.sort((a, b) => a.startTime - b.startTime);
|
|
|
|
// Update state
|
|
setClipSegments(newSegments);
|
|
saveState('split_segment');
|
|
}
|
|
};
|
|
|
|
// Handle delete segment event
|
|
const handleDeleteSegment = async (e: Event) => {
|
|
const customEvent = e as CustomEvent;
|
|
if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') {
|
|
const segmentId = customEvent.detail.segmentId;
|
|
|
|
// Find and remove the segment
|
|
const newSegments = clipSegments.filter(segment => segment.id !== segmentId);
|
|
|
|
if (newSegments.length !== clipSegments.length) {
|
|
// If all segments are deleted, create a new full video segment
|
|
if (newSegments.length === 0 && videoRef.current) {
|
|
// Create a new default segment that spans the entire video
|
|
// No need to generate a thumbnail - we'll use dynamic colors
|
|
const defaultSegment: Segment = {
|
|
id: Date.now(),
|
|
name: "segment",
|
|
startTime: 0,
|
|
endTime: videoRef.current.duration,
|
|
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
|
};
|
|
|
|
// Reset the trim points as well
|
|
setTrimStart(0);
|
|
setTrimEnd(videoRef.current.duration);
|
|
setSplitPoints([]);
|
|
setClipSegments([defaultSegment]);
|
|
} else {
|
|
// Just update the segments normally
|
|
setClipSegments(newSegments);
|
|
}
|
|
saveState('delete_segment');
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('update-segments', handleUpdateSegments as EventListener);
|
|
document.addEventListener('split-segment', handleSplitSegment as EventListener);
|
|
document.addEventListener('delete-segment', handleDeleteSegment as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener('update-segments', handleUpdateSegments as EventListener);
|
|
document.removeEventListener('split-segment', handleSplitSegment as EventListener);
|
|
document.removeEventListener('delete-segment', handleDeleteSegment as EventListener);
|
|
};
|
|
}, [clipSegments, duration]);
|
|
|
|
// Preview mode effect to handle playing only segments
|
|
useEffect(() => {
|
|
if (!isPreviewMode || !videoRef.current) return;
|
|
|
|
// Sort segments by start time
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
if (orderedSegments.length === 0) return;
|
|
|
|
const video = videoRef.current;
|
|
|
|
// Function to handle segment playback
|
|
const handleSegmentPlayback = () => {
|
|
if (!isPreviewMode || !video) return;
|
|
|
|
const currentSegment = orderedSegments[previewSegmentIndex];
|
|
if (!currentSegment) return;
|
|
|
|
const currentTime = video.currentTime;
|
|
|
|
// If we're before the current segment's start, jump to it
|
|
if (currentTime < currentSegment.startTime) {
|
|
video.currentTime = currentSegment.startTime;
|
|
return;
|
|
}
|
|
|
|
// If we've reached the end of the current segment
|
|
if (currentTime >= currentSegment.endTime - 0.01) { // Small threshold to ensure smooth transition
|
|
// Move to the next segment if available
|
|
if (previewSegmentIndex < orderedSegments.length - 1) {
|
|
// Play next segment
|
|
const nextSegment = orderedSegments[previewSegmentIndex + 1];
|
|
video.currentTime = nextSegment.startTime;
|
|
setPreviewSegmentIndex(previewSegmentIndex + 1);
|
|
|
|
logger.debug("Preview: Moving to next segment", {
|
|
from: formatDetailedTime(currentSegment.endTime),
|
|
to: formatDetailedTime(nextSegment.startTime),
|
|
segmentIndex: previewSegmentIndex + 1
|
|
});
|
|
|
|
} else {
|
|
// Loop back to first segment
|
|
logger.debug("Preview: Looping back to first segment");
|
|
video.currentTime = orderedSegments[0].startTime;
|
|
setPreviewSegmentIndex(0);
|
|
}
|
|
|
|
// Ensure playback continues
|
|
video.play().catch(err => {
|
|
console.error("Error continuing preview playback:", err);
|
|
});
|
|
}
|
|
};
|
|
|
|
// Add event listener for timeupdate to check segment boundaries
|
|
video.addEventListener('timeupdate', handleSegmentPlayback);
|
|
|
|
// Start playing if not already playing
|
|
if (video.paused) {
|
|
video.currentTime = orderedSegments[previewSegmentIndex].startTime;
|
|
video.play().catch(err => {
|
|
console.error("Error starting preview playback:", err);
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
if (video) {
|
|
video.removeEventListener('timeupdate', handleSegmentPlayback);
|
|
}
|
|
};
|
|
}, [isPreviewMode, previewSegmentIndex, clipSegments]);
|
|
|
|
// Handle starting preview mode
|
|
const handleStartPreview = () => {
|
|
const video = videoRef.current;
|
|
if (!video || clipSegments.length === 0) return;
|
|
|
|
// If preview is already active, do nothing
|
|
if (isPreviewMode) {
|
|
return;
|
|
}
|
|
|
|
// If normal playback is happening, pause it
|
|
if (isPlaying) {
|
|
video.pause();
|
|
setIsPlaying(false);
|
|
}
|
|
|
|
// Sort segments by start time
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
if (orderedSegments.length === 0) return;
|
|
|
|
// Set the preview mode flag
|
|
setIsPreviewMode(true);
|
|
logger.debug("Entering preview mode");
|
|
|
|
// Set the first segment as the current one in the preview sequence
|
|
setPreviewSegmentIndex(0);
|
|
|
|
// Move to the start of the first segment
|
|
video.currentTime = orderedSegments[0].startTime;
|
|
};
|
|
|
|
// Handle playing/stopping preview mode
|
|
const handlePreview = () => {
|
|
const video = videoRef.current;
|
|
if (!video || clipSegments.length === 0) return;
|
|
|
|
// If preview is already active, turn it off
|
|
if (isPreviewMode) {
|
|
setIsPreviewMode(false);
|
|
|
|
// Always pause the video when exiting preview mode
|
|
video.pause();
|
|
setIsPlaying(false);
|
|
|
|
logger.debug("Exiting preview mode - video paused");
|
|
return;
|
|
}
|
|
|
|
// Sort segments by start time
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
if (orderedSegments.length === 0) return;
|
|
|
|
// Set the preview mode flag
|
|
setIsPreviewMode(true);
|
|
logger.debug("Entering preview mode");
|
|
|
|
// Set the first segment as the current one in the preview sequence
|
|
setPreviewSegmentIndex(0);
|
|
|
|
// Start preview mode by playing the first segment
|
|
video.currentTime = orderedSegments[0].startTime;
|
|
|
|
// Start playback
|
|
video.play()
|
|
.then(() => {
|
|
setIsPlaying(true);
|
|
logger.debug("Preview started successfully");
|
|
})
|
|
.catch(err => {
|
|
console.error("Error starting preview:", err);
|
|
setIsPreviewMode(false);
|
|
setIsPlaying(false);
|
|
});
|
|
};
|
|
|
|
// Handle trim start change
|
|
const handleTrimStartChange = (time: number) => {
|
|
setTrimStart(time);
|
|
saveState('adjust_trim_start');
|
|
};
|
|
|
|
// Handle trim end change
|
|
const handleTrimEndChange = (time: number) => {
|
|
setTrimEnd(time);
|
|
saveState('adjust_trim_end');
|
|
};
|
|
|
|
// Handle split at current position
|
|
const handleSplit = async () => {
|
|
if (!videoRef.current) return;
|
|
|
|
// Add current time to split points if not already present
|
|
if (!splitPoints.includes(currentTime)) {
|
|
const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
|
|
setSplitPoints(newSplitPoints);
|
|
|
|
// Generate segments based on split points
|
|
const newSegments: Segment[] = [];
|
|
let startTime = 0;
|
|
|
|
for (let i = 0; i <= newSplitPoints.length; i++) {
|
|
const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
|
|
|
|
if (startTime < endTime) {
|
|
// No need to generate thumbnails - we'll use dynamic colors
|
|
newSegments.push({
|
|
id: Date.now() + i,
|
|
name: `Segment ${i + 1}`,
|
|
startTime,
|
|
endTime,
|
|
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
|
});
|
|
|
|
startTime = endTime;
|
|
}
|
|
}
|
|
|
|
setClipSegments(newSegments);
|
|
saveState('create_split_points');
|
|
}
|
|
};
|
|
|
|
// Handle reset of all edits
|
|
const handleReset = async () => {
|
|
setTrimStart(0);
|
|
setTrimEnd(duration);
|
|
setSplitPoints([]);
|
|
|
|
// Create a new default segment that spans the entire video
|
|
if (!videoRef.current) return;
|
|
|
|
// No need to generate thumbnails - we'll use dynamic colors
|
|
const defaultSegment: Segment = {
|
|
id: Date.now(),
|
|
name: "segment",
|
|
startTime: 0,
|
|
endTime: duration,
|
|
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
|
|
};
|
|
|
|
setClipSegments([defaultSegment]);
|
|
saveState('reset_all');
|
|
};
|
|
|
|
// Handle undo
|
|
const handleUndo = () => {
|
|
if (historyPosition > 0) {
|
|
const previousState = history[historyPosition - 1];
|
|
logger.debug(`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`);
|
|
|
|
// Log segment details to help debug
|
|
logger.debug("Segment details after undo:", previousState.clipSegments.map(seg =>
|
|
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
|
));
|
|
|
|
// Apply the previous state with deep cloning to avoid reference issues
|
|
setTrimStart(previousState.trimStart);
|
|
setTrimEnd(previousState.trimEnd);
|
|
setSplitPoints([...previousState.splitPoints]);
|
|
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
|
|
setHistoryPosition(historyPosition - 1);
|
|
} else {
|
|
logger.debug("Cannot undo: at earliest history position");
|
|
}
|
|
};
|
|
|
|
// Handle redo
|
|
const handleRedo = () => {
|
|
if (historyPosition < history.length - 1) {
|
|
const nextState = history[historyPosition + 1];
|
|
logger.debug(`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`);
|
|
|
|
// Log segment details to help debug
|
|
logger.debug("Segment details after redo:", nextState.clipSegments.map(seg =>
|
|
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
|
));
|
|
|
|
// Apply the next state with deep cloning to avoid reference issues
|
|
setTrimStart(nextState.trimStart);
|
|
setTrimEnd(nextState.trimEnd);
|
|
setSplitPoints([...nextState.splitPoints]);
|
|
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
|
|
setHistoryPosition(historyPosition + 1);
|
|
} else {
|
|
logger.debug("Cannot redo: at latest history position");
|
|
}
|
|
};
|
|
|
|
// Handle zoom level change
|
|
const handleZoomChange = (level: number) => {
|
|
setZoomLevel(level);
|
|
};
|
|
|
|
// Handle play/pause of the full video
|
|
const handlePlay = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
// If in preview mode, exit it before toggling normal play
|
|
if (isPreviewMode) {
|
|
setIsPreviewMode(false);
|
|
// Don't immediately start playing when exiting preview mode
|
|
// Just update the state and return
|
|
setIsPlaying(false);
|
|
video.pause();
|
|
return;
|
|
}
|
|
|
|
if (isPlaying) {
|
|
// Pause the video
|
|
video.pause();
|
|
setIsPlaying(false);
|
|
} else {
|
|
// iOS Safari fix: Check for lastSeekedPosition
|
|
if (typeof window !== 'undefined' && window.lastSeekedPosition > 0) {
|
|
// Only seek if the position is significantly different
|
|
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
|
console.log("handlePlay: Using lastSeekedPosition", window.lastSeekedPosition);
|
|
video.currentTime = window.lastSeekedPosition;
|
|
}
|
|
}
|
|
|
|
// Play the video from current position with proper promise handling
|
|
video.play()
|
|
.then(() => {
|
|
setIsPlaying(true);
|
|
// Reset lastSeekedPosition after successful play
|
|
if (typeof window !== 'undefined') {
|
|
window.lastSeekedPosition = 0;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Error playing video:", err);
|
|
setIsPlaying(false); // Reset state if play failed
|
|
});
|
|
}
|
|
};
|
|
|
|
// Toggle mute state
|
|
const toggleMute = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.muted = !video.muted;
|
|
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)
|
|
}))
|
|
};
|
|
|
|
// Display JSON in alert (for demonstration purposes)
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.debug("Saving data:", saveData);
|
|
}
|
|
|
|
// Mark as saved - no unsaved changes
|
|
setHasUnsavedChanges(false);
|
|
|
|
// Debug message
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.debug("Changes saved - reset unsaved changes flag");
|
|
}
|
|
|
|
// 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)
|
|
}))
|
|
};
|
|
|
|
// 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 seeking with mobile check
|
|
const handleMobileSafeSeek = (time: number) => {
|
|
// Only allow seeking if not on mobile or if video has been played
|
|
if (!isMobile || videoInitialized) {
|
|
seekVideo(time);
|
|
}
|
|
};
|
|
|
|
// Check if device is mobile
|
|
const isMobile = typeof window !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent);
|
|
|
|
// Add videoInitialized state
|
|
const [videoInitialized, setVideoInitialized] = useState(false);
|
|
|
|
// Effect to handle segments playback
|
|
useEffect(() => {
|
|
if (!isPlayingSegments || !videoRef.current) return;
|
|
|
|
const video = videoRef.current;
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
|
|
const handleSegmentsPlayback = () => {
|
|
const currentSegment = orderedSegments[currentSegmentIndex];
|
|
if (!currentSegment) return;
|
|
|
|
const currentTime = video.currentTime;
|
|
|
|
// If we're before the current segment's start, jump to it
|
|
if (currentTime < currentSegment.startTime) {
|
|
video.currentTime = currentSegment.startTime;
|
|
return;
|
|
}
|
|
|
|
// If we've reached the end of the current segment
|
|
if (currentTime >= currentSegment.endTime - 0.01) {
|
|
if (currentSegmentIndex < orderedSegments.length - 1) {
|
|
// Move to next segment
|
|
const nextSegment = orderedSegments[currentSegmentIndex + 1];
|
|
video.currentTime = nextSegment.startTime;
|
|
setCurrentSegmentIndex(currentSegmentIndex + 1);
|
|
|
|
// If video is somehow paused, ensure it keeps playing
|
|
if (video.paused) {
|
|
logger.debug("Ensuring playback continues to next segment");
|
|
video.play().catch(err => {
|
|
console.error("Error continuing segment playback:", err);
|
|
});
|
|
}
|
|
} else {
|
|
// End of all segments - only pause when we reach the very end
|
|
video.pause();
|
|
setIsPlayingSegments(false);
|
|
setCurrentSegmentIndex(0);
|
|
video.removeEventListener('timeupdate', handleSegmentsPlayback);
|
|
}
|
|
}
|
|
};
|
|
|
|
video.addEventListener('timeupdate', handleSegmentsPlayback);
|
|
|
|
// Start playing if not already playing
|
|
if (video.paused && orderedSegments.length > 0) {
|
|
video.currentTime = orderedSegments[0].startTime;
|
|
video.play().catch(console.error);
|
|
}
|
|
|
|
return () => {
|
|
video.removeEventListener('timeupdate', handleSegmentsPlayback);
|
|
};
|
|
}, [isPlayingSegments, currentSegmentIndex, clipSegments]);
|
|
|
|
// Effect to handle manual segment index updates during segments playback
|
|
useEffect(() => {
|
|
const handleSegmentIndexUpdate = (event: CustomEvent) => {
|
|
const { segmentIndex } = event.detail;
|
|
if (isPlayingSegments && segmentIndex !== currentSegmentIndex) {
|
|
logger.debug(`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`);
|
|
setCurrentSegmentIndex(segmentIndex);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
|
|
};
|
|
}, [isPlayingSegments, currentSegmentIndex]);
|
|
|
|
// Handle play segments
|
|
const handlePlaySegments = () => {
|
|
const video = videoRef.current;
|
|
if (!video || clipSegments.length === 0) return;
|
|
|
|
if (isPlayingSegments) {
|
|
// Stop segments playback
|
|
video.pause();
|
|
setIsPlayingSegments(false);
|
|
setCurrentSegmentIndex(0);
|
|
} else {
|
|
// Start segments playback
|
|
setIsPlayingSegments(true);
|
|
setCurrentSegmentIndex(0);
|
|
|
|
// Exit preview mode if active
|
|
if (isPreviewMode) {
|
|
setIsPreviewMode(false);
|
|
}
|
|
|
|
// Sort segments by start time
|
|
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
|
|
|
// Start from the first segment
|
|
video.currentTime = orderedSegments[0].startTime;
|
|
|
|
// Start playback with proper error handling
|
|
video.play().catch(err => {
|
|
console.error("Error starting segments playback:", err);
|
|
setIsPlayingSegments(false);
|
|
});
|
|
|
|
logger.debug("Starting playback of all segments continuously");
|
|
}
|
|
};
|
|
|
|
return {
|
|
videoRef,
|
|
currentTime,
|
|
duration,
|
|
isPlaying,
|
|
setIsPlaying,
|
|
isMuted,
|
|
isPreviewMode,
|
|
isPlayingSegments,
|
|
thumbnails,
|
|
trimStart,
|
|
trimEnd,
|
|
splitPoints,
|
|
zoomLevel,
|
|
clipSegments,
|
|
hasUnsavedChanges,
|
|
historyPosition,
|
|
history,
|
|
handleTrimStartChange,
|
|
handleTrimEndChange,
|
|
handleZoomChange,
|
|
handleMobileSafeSeek,
|
|
handleSplit,
|
|
handleReset,
|
|
handleUndo,
|
|
handleRedo,
|
|
handlePreview,
|
|
handlePlaySegments,
|
|
toggleMute,
|
|
handleSave,
|
|
handleSaveACopy,
|
|
handleSaveSegments,
|
|
isMobile,
|
|
videoInitialized,
|
|
setVideoInitialized,
|
|
};
|
|
};
|
|
|
|
export default useVideoTrimmer;
|