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,303 +1,277 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { formatDetailedTime } from './lib/timeUtils';
|
||||||
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
import logger from './lib/logger';
|
||||||
import logger from "./lib/logger";
|
import VideoPlayer from '@/components/VideoPlayer';
|
||||||
import VideoPlayer from "@/components/VideoPlayer";
|
import TimelineControls from '@/components/TimelineControls';
|
||||||
import TimelineControls from "@/components/TimelineControls";
|
import EditingTools from '@/components/EditingTools';
|
||||||
import EditingTools from "@/components/EditingTools";
|
import ClipSegments from '@/components/ClipSegments';
|
||||||
import ClipSegments from "@/components/ClipSegments";
|
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
|
||||||
import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
import useVideoChapters from '@/hooks/useVideoChapters';
|
||||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const {
|
const {
|
||||||
videoRef,
|
videoRef,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
isMuted,
|
isMuted,
|
||||||
thumbnails,
|
thumbnails,
|
||||||
trimStart,
|
trimStart,
|
||||||
trimEnd,
|
trimEnd,
|
||||||
splitPoints,
|
splitPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
clipSegments,
|
clipSegments,
|
||||||
hasUnsavedChanges,
|
selectedSegmentId,
|
||||||
historyPosition,
|
hasUnsavedChanges,
|
||||||
history,
|
historyPosition,
|
||||||
handleTrimStartChange,
|
history,
|
||||||
handleTrimEndChange,
|
handleTrimStartChange,
|
||||||
handleZoomChange,
|
handleTrimEndChange,
|
||||||
handleMobileSafeSeek,
|
handleZoomChange,
|
||||||
handleSplit,
|
handleMobileSafeSeek,
|
||||||
handleReset,
|
handleSplit,
|
||||||
handleUndo,
|
handleReset,
|
||||||
handleRedo,
|
handleUndo,
|
||||||
toggleMute,
|
handleRedo,
|
||||||
handleSave,
|
toggleMute,
|
||||||
handleSaveACopy,
|
handleSegmentUpdate,
|
||||||
handleSaveSegments,
|
handleChapterSave,
|
||||||
isMobile,
|
handleSelectedSegmentChange,
|
||||||
videoInitialized,
|
isMobile,
|
||||||
setVideoInitialized,
|
videoInitialized,
|
||||||
isPlayingSegments,
|
setVideoInitialized,
|
||||||
handlePlaySegments
|
isPlayingSegments,
|
||||||
} = useVideoTrimmer();
|
handlePlaySegments,
|
||||||
|
} = useVideoChapters();
|
||||||
|
|
||||||
// Function to play from the beginning
|
const handlePlay = () => {
|
||||||
const playFromBeginning = () => {
|
if (!videoRef.current) return;
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.currentTime = 0;
|
|
||||||
handleMobileSafeSeek(0);
|
|
||||||
if (!isPlaying) {
|
|
||||||
handlePlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
const video = videoRef.current;
|
||||||
const jumpBackward15 = () => {
|
|
||||||
const newTime = Math.max(0, currentTime - 15);
|
|
||||||
handleMobileSafeSeek(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to jump 15 seconds forward
|
// If already playing, just pause the video
|
||||||
const jumpForward15 = () => {
|
if (isPlaying) {
|
||||||
const newTime = Math.min(duration, currentTime + 15);
|
video.pause();
|
||||||
handleMobileSafeSeek(newTime);
|
setIsPlaying(false);
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handlePlay = () => {
|
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||||
if (!videoRef.current) return;
|
|
||||||
|
|
||||||
const video = videoRef.current;
|
// Find the next stopping point based on current position
|
||||||
|
let stopTime = duration;
|
||||||
|
let currentSegment = null;
|
||||||
|
let nextSegment = null;
|
||||||
|
|
||||||
// If already playing, just pause the video
|
// Sort segments by start time to ensure correct order
|
||||||
if (isPlaying) {
|
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||||
video.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
// First, check if we're inside a segment or exactly at its start/end
|
||||||
|
currentSegment = sortedSegments.find((seg) => {
|
||||||
|
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||||
|
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||||
|
|
||||||
// Find the next stopping point based on current position
|
// Check if we're inside the segment
|
||||||
let stopTime = duration;
|
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||||
let currentSegment = null;
|
return true;
|
||||||
let nextSegment = null;
|
}
|
||||||
|
// Check if we're exactly at the start
|
||||||
|
if (currentPosition === segStartTime) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if we're exactly at the end
|
||||||
|
if (currentPosition === segEndTime) {
|
||||||
|
// If we're at the end of a segment, we should look for the next one
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// Sort segments by start time to ensure correct order
|
// If we're not in a segment, find the next segment
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
if (!currentSegment) {
|
||||||
|
nextSegment = sortedSegments.find((seg) => {
|
||||||
|
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||||
|
return segStartTime > currentPosition;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// First, check if we're inside a segment or exactly at its start/end
|
// Determine where to stop based on position
|
||||||
currentSegment = sortedSegments.find((seg) => {
|
if (currentSegment) {
|
||||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
// If we're in a segment, stop at its end
|
||||||
const segEndTime = Number(seg.endTime.toFixed(6));
|
stopTime = Number(currentSegment.endTime.toFixed(6));
|
||||||
|
} else if (nextSegment) {
|
||||||
|
// If we're in a cutaway and there's a next segment, stop at its start
|
||||||
|
stopTime = Number(nextSegment.startTime.toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we're inside the segment
|
// Create a boundary checker function with high precision
|
||||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
const checkBoundary = () => {
|
||||||
return true;
|
if (!video) return;
|
||||||
}
|
|
||||||
// Check if we're exactly at the start
|
|
||||||
if (currentPosition === segStartTime) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Check if we're exactly at the end
|
|
||||||
if (currentPosition === segEndTime) {
|
|
||||||
// If we're at the end of a segment, we should look for the next one
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we're not in a segment, find the next segment
|
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||||
if (!currentSegment) {
|
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||||
nextSegment = sortedSegments.find((seg) => {
|
|
||||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
|
||||||
return segStartTime > currentPosition;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine where to stop based on position
|
// If we've reached or passed the boundary
|
||||||
if (currentSegment) {
|
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||||
// If we're in a segment, stop at its end
|
// First pause playback
|
||||||
stopTime = Number(currentSegment.endTime.toFixed(6));
|
video.pause();
|
||||||
} else if (nextSegment) {
|
|
||||||
// If we're in a cutaway and there's a next segment, stop at its start
|
|
||||||
stopTime = Number(nextSegment.startTime.toFixed(6));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a boundary checker function with high precision
|
// Force exact position with multiple verification attempts
|
||||||
const checkBoundary = () => {
|
const setExactPosition = () => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
// Set to exact boundary time
|
||||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
video.currentTime = stopTime;
|
||||||
|
handleMobileSafeSeek(stopTime);
|
||||||
|
|
||||||
// If we've reached or passed the boundary
|
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||||
// First pause playback
|
|
||||||
video.pause();
|
|
||||||
|
|
||||||
// Force exact position with multiple verification attempts
|
logger.debug('Position verification:', {
|
||||||
const setExactPosition = () => {
|
target: formatDetailedTime(stopTime),
|
||||||
if (!video) return;
|
actual: formatDetailedTime(actualPosition),
|
||||||
|
difference: difference,
|
||||||
|
});
|
||||||
|
|
||||||
// Set to exact boundary time
|
// If we're not exactly at the target position, try one more time
|
||||||
video.currentTime = stopTime;
|
if (difference > 0) {
|
||||||
handleMobileSafeSeek(stopTime);
|
video.currentTime = stopTime;
|
||||||
|
handleMobileSafeSeek(stopTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
// Multiple attempts to ensure precision, with increasing delays
|
||||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
setExactPosition();
|
||||||
|
setTimeout(setExactPosition, 5); // Quick first retry
|
||||||
|
setTimeout(setExactPosition, 10); // Second retry
|
||||||
|
setTimeout(setExactPosition, 20); // Third retry if needed
|
||||||
|
setTimeout(setExactPosition, 50); // Final verification
|
||||||
|
|
||||||
logger.debug("Position verification:", {
|
// Remove our boundary checker
|
||||||
target: formatDetailedTime(stopTime),
|
video.removeEventListener('timeupdate', checkBoundary);
|
||||||
actual: formatDetailedTime(actualPosition),
|
setIsPlaying(false);
|
||||||
difference: difference
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we're not exactly at the target position, try one more time
|
// Log the final position for debugging
|
||||||
if (difference > 0) {
|
logger.debug('Stopped at position:', {
|
||||||
video.currentTime = stopTime;
|
target: formatDetailedTime(stopTime),
|
||||||
handleMobileSafeSeek(stopTime);
|
actual: formatDetailedTime(video.currentTime),
|
||||||
}
|
type: currentSegment ? 'segment end' : nextSegment ? 'next segment start' : 'end of video',
|
||||||
|
segment: currentSegment
|
||||||
|
? {
|
||||||
|
id: currentSegment.id,
|
||||||
|
start: formatDetailedTime(currentSegment.startTime),
|
||||||
|
end: formatDetailedTime(currentSegment.endTime),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
nextSegment: nextSegment
|
||||||
|
? {
|
||||||
|
id: nextSegment.id,
|
||||||
|
start: formatDetailedTime(nextSegment.startTime),
|
||||||
|
end: formatDetailedTime(nextSegment.endTime),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Multiple attempts to ensure precision, with increasing delays
|
// Start our boundary checker
|
||||||
setExactPosition();
|
video.addEventListener('timeupdate', checkBoundary);
|
||||||
setTimeout(setExactPosition, 5); // Quick first retry
|
|
||||||
setTimeout(setExactPosition, 10); // Second retry
|
|
||||||
setTimeout(setExactPosition, 20); // Third retry if needed
|
|
||||||
setTimeout(setExactPosition, 50); // Final verification
|
|
||||||
|
|
||||||
// Remove our boundary checker
|
// Start playing
|
||||||
video.removeEventListener("timeupdate", checkBoundary);
|
video
|
||||||
setIsPlaying(false);
|
.play()
|
||||||
|
.then(() => {
|
||||||
// Log the final position for debugging
|
setIsPlaying(true);
|
||||||
logger.debug("Stopped at position:", {
|
setVideoInitialized(true);
|
||||||
target: formatDetailedTime(stopTime),
|
logger.debug('Playback started:', {
|
||||||
actual: formatDetailedTime(video.currentTime),
|
from: formatDetailedTime(currentPosition),
|
||||||
type: currentSegment
|
to: formatDetailedTime(stopTime),
|
||||||
? "segment end"
|
currentSegment: currentSegment
|
||||||
: nextSegment
|
? {
|
||||||
? "next segment start"
|
id: currentSegment.id,
|
||||||
: "end of video",
|
start: formatDetailedTime(currentSegment.startTime),
|
||||||
segment: currentSegment
|
end: formatDetailedTime(currentSegment.endTime),
|
||||||
? {
|
}
|
||||||
id: currentSegment.id,
|
: 'None',
|
||||||
start: formatDetailedTime(currentSegment.startTime),
|
nextSegment: nextSegment
|
||||||
end: formatDetailedTime(currentSegment.endTime)
|
? {
|
||||||
}
|
id: nextSegment.id,
|
||||||
: null,
|
start: formatDetailedTime(nextSegment.startTime),
|
||||||
nextSegment: nextSegment
|
end: formatDetailedTime(nextSegment.endTime),
|
||||||
? {
|
}
|
||||||
id: nextSegment.id,
|
: 'None',
|
||||||
start: formatDetailedTime(nextSegment.startTime),
|
});
|
||||||
end: formatDetailedTime(nextSegment.endTime)
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
: null
|
console.error('Error playing video:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start our boundary checker
|
return (
|
||||||
video.addEventListener("timeupdate", checkBoundary);
|
<div className="bg-background min-h-screen">
|
||||||
|
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||||
|
|
||||||
// Start playing
|
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||||
video
|
{/* Video Player */}
|
||||||
.play()
|
<VideoPlayer
|
||||||
.then(() => {
|
videoRef={videoRef}
|
||||||
setIsPlaying(true);
|
currentTime={currentTime}
|
||||||
setVideoInitialized(true);
|
duration={duration}
|
||||||
logger.debug("Playback started:", {
|
isPlaying={isPlaying}
|
||||||
from: formatDetailedTime(currentPosition),
|
isMuted={isMuted}
|
||||||
to: formatDetailedTime(stopTime),
|
onPlayPause={handlePlay}
|
||||||
currentSegment: currentSegment
|
onSeek={handleMobileSafeSeek}
|
||||||
? {
|
onToggleMute={toggleMute}
|
||||||
id: currentSegment.id,
|
/>
|
||||||
start: formatDetailedTime(currentSegment.startTime),
|
|
||||||
end: formatDetailedTime(currentSegment.endTime)
|
|
||||||
}
|
|
||||||
: "None",
|
|
||||||
nextSegment: nextSegment
|
|
||||||
? {
|
|
||||||
id: nextSegment.id,
|
|
||||||
start: formatDetailedTime(nextSegment.startTime),
|
|
||||||
end: formatDetailedTime(nextSegment.endTime)
|
|
||||||
}
|
|
||||||
: "None"
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Error playing video:", err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
{/* Editing Tools */}
|
||||||
<div className="bg-background min-h-screen">
|
<EditingTools
|
||||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
onSplit={handleSplit}
|
||||||
|
onReset={handleReset}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onRedo={handleRedo}
|
||||||
|
onPlaySegments={handlePlaySegments}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isPlayingSegments={isPlayingSegments}
|
||||||
|
canUndo={historyPosition > 0}
|
||||||
|
canRedo={historyPosition < history.length - 1}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
{/* Timeline Controls */}
|
||||||
{/* Video Player */}
|
<TimelineControls
|
||||||
<VideoPlayer
|
currentTime={currentTime}
|
||||||
videoRef={videoRef}
|
duration={duration}
|
||||||
currentTime={currentTime}
|
thumbnails={thumbnails}
|
||||||
duration={duration}
|
trimStart={trimStart}
|
||||||
isPlaying={isPlaying}
|
trimEnd={trimEnd}
|
||||||
isMuted={isMuted}
|
splitPoints={splitPoints}
|
||||||
onPlayPause={handlePlay}
|
zoomLevel={zoomLevel}
|
||||||
onSeek={handleMobileSafeSeek}
|
clipSegments={clipSegments}
|
||||||
onToggleMute={toggleMute}
|
selectedSegmentId={selectedSegmentId}
|
||||||
/>
|
onSelectedSegmentChange={handleSelectedSegmentChange}
|
||||||
|
onSegmentUpdate={handleSegmentUpdate}
|
||||||
|
onChapterSave={handleChapterSave}
|
||||||
|
onTrimStartChange={handleTrimStartChange}
|
||||||
|
onTrimEndChange={handleTrimEndChange}
|
||||||
|
onZoomChange={handleZoomChange}
|
||||||
|
onSeek={handleMobileSafeSeek}
|
||||||
|
videoRef={videoRef}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
isIOSUninitialized={isMobile && !videoInitialized}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
setIsPlaying={setIsPlaying}
|
||||||
|
onPlayPause={handlePlay}
|
||||||
|
isPlayingSegments={isPlayingSegments}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Editing Tools */}
|
{/* Clip Segments */}
|
||||||
<EditingTools
|
<ClipSegments segments={clipSegments} selectedSegmentId={selectedSegmentId} />
|
||||||
onSplit={handleSplit}
|
</div>
|
||||||
onReset={handleReset}
|
</div>
|
||||||
onUndo={handleUndo}
|
);
|
||||||
onRedo={handleRedo}
|
|
||||||
onPlaySegments={handlePlaySegments}
|
|
||||||
onPlay={handlePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isPlayingSegments={isPlayingSegments}
|
|
||||||
canUndo={historyPosition > 0}
|
|
||||||
canRedo={historyPosition < history.length - 1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Timeline Controls */}
|
|
||||||
<TimelineControls
|
|
||||||
currentTime={currentTime}
|
|
||||||
duration={duration}
|
|
||||||
thumbnails={thumbnails}
|
|
||||||
trimStart={trimStart}
|
|
||||||
trimEnd={trimEnd}
|
|
||||||
splitPoints={splitPoints}
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
clipSegments={clipSegments}
|
|
||||||
onTrimStartChange={handleTrimStartChange}
|
|
||||||
onTrimEndChange={handleTrimEndChange}
|
|
||||||
onZoomChange={handleZoomChange}
|
|
||||||
onSeek={handleMobileSafeSeek}
|
|
||||||
videoRef={videoRef}
|
|
||||||
onSave={handleSave}
|
|
||||||
onSaveACopy={handleSaveACopy}
|
|
||||||
onSaveSegments={handleSaveSegments}
|
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
|
||||||
isIOSUninitialized={isMobile && !videoInitialized}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
setIsPlaying={setIsPlaying}
|
|
||||||
onPlayPause={handlePlay}
|
|
||||||
isPlayingSegments={isPlayingSegments}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Clip Segments */}
|
|
||||||
<ClipSegments segments={clipSegments} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@ -7,13 +7,15 @@ export interface Segment {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
|
chapterTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipSegmentsProps {
|
interface ClipSegmentsProps {
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
|
selectedSegmentId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
|
||||||
// Sort segments by startTime
|
// Sort segments by startTime
|
||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.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}`;
|
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get selected segment
|
||||||
|
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="clip-segments-container">
|
<div className="clip-segments-container">
|
||||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
<h3 className="clip-segments-title">Chapters</h3>
|
||||||
|
|
||||||
{sortedSegments.map((segment, index) => (
|
{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-content">
|
||||||
<div
|
<div
|
||||||
className="segment-thumbnail"
|
className="segment-thumbnail"
|
||||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||||
></div>
|
></div>
|
||||||
<div className="segment-info">
|
<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">
|
<div className="segment-time">
|
||||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||||
</div>
|
</div>
|
||||||
@ -74,7 +88,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{sortedSegments.length === 0 && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -35,19 +35,20 @@ interface TimelineControlsProps {
|
|||||||
splitPoints: number[];
|
splitPoints: number[];
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
clipSegments: Segment[];
|
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;
|
onTrimStartChange: (time: number) => void;
|
||||||
onTrimEndChange: (time: number) => void;
|
onTrimEndChange: (time: number) => void;
|
||||||
onZoomChange: (level: number) => void;
|
onZoomChange: (level: number) => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
videoRef: React.RefObject<HTMLVideoElement>;
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
onSave?: () => void;
|
|
||||||
onSaveACopy?: () => void;
|
|
||||||
onSaveSegments?: () => void;
|
|
||||||
hasUnsavedChanges?: boolean;
|
hasUnsavedChanges?: boolean;
|
||||||
isIOSUninitialized?: boolean;
|
isIOSUninitialized?: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
onPlayPause: () => void; // Add this prop
|
onPlayPause: () => void;
|
||||||
isPlayingSegments?: boolean;
|
isPlayingSegments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,14 +110,15 @@ const TimelineControls = ({
|
|||||||
splitPoints,
|
splitPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
clipSegments,
|
clipSegments,
|
||||||
|
selectedSegmentId: externalSelectedSegmentId,
|
||||||
|
onSelectedSegmentChange,
|
||||||
|
onSegmentUpdate,
|
||||||
|
onChapterSave,
|
||||||
onTrimStartChange,
|
onTrimStartChange,
|
||||||
onTrimEndChange,
|
onTrimEndChange,
|
||||||
onZoomChange,
|
onZoomChange,
|
||||||
onSeek,
|
onSeek,
|
||||||
videoRef,
|
videoRef,
|
||||||
onSave,
|
|
||||||
onSaveACopy,
|
|
||||||
onSaveSegments,
|
|
||||||
hasUnsavedChanges = false,
|
hasUnsavedChanges = false,
|
||||||
isIOSUninitialized = false,
|
isIOSUninitialized = false,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@ -127,7 +129,17 @@ const TimelineControls = ({
|
|||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const leftHandleRef = useRef<HTMLDivElement>(null);
|
const leftHandleRef = useRef<HTMLDivElement>(null);
|
||||||
const rightHandleRef = 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 [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false);
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||||
const [clickedTime, setClickedTime] = useState<number>(0);
|
const [clickedTime, setClickedTime] = useState<number>(0);
|
||||||
@ -142,6 +154,49 @@ const TimelineControls = ({
|
|||||||
// Reference for the scrollable container
|
// Reference for the scrollable container
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
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
|
// Helper function for time adjustment buttons to maintain playback state
|
||||||
const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
|
const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -310,16 +365,14 @@ const TimelineControls = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false);
|
||||||
const [showSaveAsModal, setShowSaveAsModal] = useState(false);
|
|
||||||
const [showSaveSegmentsModal, setShowSaveSegmentsModal] = useState(false);
|
|
||||||
const [showProcessingModal, setShowProcessingModal] = useState(false);
|
const [showProcessingModal, setShowProcessingModal] = useState(false);
|
||||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [redirectUrl, setRedirectUrl] = useState('');
|
const [redirectUrl, setRedirectUrl] = useState('');
|
||||||
const [saveType, setSaveType] = useState<'save' | 'copy' | 'segments'>('save');
|
const [saveType, setSaveType] = useState<'chapters'>('chapters');
|
||||||
|
|
||||||
// Calculate positions as percentages
|
// Calculate positions as percentages
|
||||||
const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
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
|
// No need for an extra effect here as we handle displayTime updates in the segment playback effect
|
||||||
|
|
||||||
// Save and API handlers
|
// Save Chapters handler
|
||||||
const handleSaveConfirm = async () => {
|
const handleSaveChaptersConfirm = async () => {
|
||||||
// Close confirmation modal and show processing modal
|
// Close confirmation modal and show processing modal
|
||||||
setShowSaveModal(false);
|
setShowSaveChaptersModal(false);
|
||||||
setShowProcessingModal(true);
|
setShowProcessingModal(true);
|
||||||
setSaveType('save');
|
setSaveType('chapters');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Format segments data for API request
|
// Format chapters data for API request
|
||||||
const segments = clipSegments.map((segment) => ({
|
const chapters = clipSegments
|
||||||
startTime: formatDetailedTime(segment.startTime),
|
.filter((segment) => segment.chapterTitle && segment.chapterTitle.trim())
|
||||||
endTime: formatDetailedTime(segment.endTime),
|
.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;
|
if (chapters.length === 0) {
|
||||||
const redirectURL = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.redirectURL) || null;
|
setErrorMessage('No chapters with titles found');
|
||||||
|
setShowErrorModal(true);
|
||||||
|
setShowProcessingModal(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Log the request details for debugging
|
// Call the onChapterSave function if provided
|
||||||
logger.debug('Save request:', {
|
if (onChapterSave) {
|
||||||
mediaId,
|
await onChapterSave(chapters);
|
||||||
segments,
|
setShowProcessingModal(false);
|
||||||
saveAsCopy: false,
|
setSuccessMessage('Chapters saved successfully!');
|
||||||
redirectURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await trimVideo(mediaId, {
|
// Set redirect URL to media page
|
||||||
segments,
|
const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null;
|
||||||
saveAsCopy: false,
|
if (mediaId) {
|
||||||
});
|
setRedirectUrl(`/view?m=${mediaId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Log the response for debugging
|
|
||||||
logger.debug('Save response:', response);
|
|
||||||
|
|
||||||
// Hide processing modal
|
|
||||||
setShowProcessingModal(false);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
setRedirectUrl(finalRedirectUrl);
|
|
||||||
setSuccessMessage('Video saved successfully!');
|
|
||||||
|
|
||||||
// Show success modal
|
|
||||||
setShowSuccessModal(true);
|
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 {
|
} else {
|
||||||
// Handle other status codes as needed
|
setErrorMessage('Chapter save function not available');
|
||||||
logger.debug('Save error (unknown status):', response);
|
|
||||||
setErrorMessage('An unexpected error occurred');
|
|
||||||
setShowErrorModal(true);
|
setShowErrorModal(true);
|
||||||
|
setShowProcessingModal(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing video:', error);
|
logger.error('Error saving chapters:', error);
|
||||||
setShowProcessingModal(false);
|
setShowProcessingModal(false);
|
||||||
|
|
||||||
// Set error message and show error modal
|
// Set error message and show error modal
|
||||||
const errorMsg = error instanceof Error ? error.message : 'An error occurred during processing';
|
const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters';
|
||||||
logger.debug('Save error (exception):', errorMsg);
|
logger.debug('Save chapters 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);
|
|
||||||
setErrorMessage(errorMsg);
|
setErrorMessage(errorMsg);
|
||||||
setShowErrorModal(true);
|
setShowErrorModal(true);
|
||||||
}
|
}
|
||||||
@ -2407,9 +2297,6 @@ const TimelineControls = ({
|
|||||||
|
|
||||||
// Set redirect timeout
|
// Set redirect timeout
|
||||||
redirectTimeout = setTimeout(() => {
|
redirectTimeout = setTimeout(() => {
|
||||||
// Reset unsaved changes flag before navigating away
|
|
||||||
if (onSave) onSave();
|
|
||||||
|
|
||||||
// Redirect to the URL
|
// Redirect to the URL
|
||||||
logger.debug('Automatically redirecting to:', redirectUrl);
|
logger.debug('Automatically redirecting to:', redirectUrl);
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
@ -2421,7 +2308,15 @@ const TimelineControls = ({
|
|||||||
if (countdownInterval) clearInterval(countdownInterval);
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
if (redirectTimeout) clearTimeout(redirectTimeout);
|
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 (
|
return (
|
||||||
<div className={`timeline-container-card ${isPlayingSegments ? 'segments-playback-mode' : ''}`}>
|
<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 */}
|
{/* First row with time adjustment buttons */}
|
||||||
<div className="tooltip-row">
|
<div className="tooltip-row">
|
||||||
<button
|
<button
|
||||||
@ -2600,7 +2512,6 @@ const TimelineControls = ({
|
|||||||
+50ms
|
+50ms
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second row with action buttons */}
|
{/* Second row with action buttons */}
|
||||||
<div className="tooltip-row tooltip-actions">
|
<div className="tooltip-row tooltip-actions">
|
||||||
<button
|
<button
|
||||||
@ -4329,6 +4240,7 @@ const TimelineControls = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Helper function to show tooltip at current position */}
|
{/* Helper function to show tooltip at current position */}
|
||||||
{/* This is defined within the component to access state variables and functions */}
|
{/* This is defined within the component to access state variables and functions */}
|
||||||
<div className="time-button-group">
|
<div className="time-button-group">
|
||||||
@ -4512,99 +4424,44 @@ const TimelineControls = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Buttons Row */}
|
{/* Save Chapters Button */}
|
||||||
<div className="save-buttons-row">
|
<div className="save-buttons-row">
|
||||||
{onSave && (
|
<button
|
||||||
<button
|
onClick={() => setShowSaveChaptersModal(true)}
|
||||||
onClick={() => setShowSaveModal(true)}
|
className="save-chapters-button"
|
||||||
className="save-button"
|
data-tooltip="Save chapters"
|
||||||
data-tooltip="Save changes"
|
disabled={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0}
|
||||||
>
|
>
|
||||||
Save
|
Save Chapters
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Save Confirmation Modal */}
|
{/* Save Confirmation Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showSaveModal}
|
isOpen={showSaveChaptersModal}
|
||||||
onClose={() => setShowSaveModal(false)}
|
onClose={() => setShowSaveChaptersModal(false)}
|
||||||
title="Save Changes"
|
title="Save Chapters"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="modal-button modal-button-secondary"
|
className="modal-button modal-button-secondary"
|
||||||
onClick={() => setShowSaveModal(false)}
|
onClick={() => setShowSaveChaptersModal(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="modal-button modal-button-primary"
|
className="modal-button modal-button-primary"
|
||||||
onClick={() => {
|
onClick={handleSaveChaptersConfirm}
|
||||||
// Reset unsaved changes flag before saving
|
|
||||||
if (onSave) onSave();
|
|
||||||
handleSaveConfirm();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Confirm Save
|
Save Chapters
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="modal-message">
|
<p className="modal-message">
|
||||||
You're about to replace the original video with this trimmed version. This can't be undone.
|
Are you sure you want to save the chapters? This will save{' '}
|
||||||
</p>
|
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the
|
||||||
</Modal>
|
database.
|
||||||
|
|
||||||
{/* 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.
|
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@ -4616,38 +4473,6 @@ const TimelineControls = ({
|
|||||||
<p className="modal-message text-center">Please wait while your video is being processed...</p>
|
<p className="modal-message text-center">Please wait while your video is being processed...</p>
|
||||||
</Modal>
|
</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 */}
|
{/* Success Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showSuccessModal}
|
isOpen={showSuccessModal}
|
||||||
@ -4660,17 +4485,13 @@ const TimelineControls = ({
|
|||||||
</p> */}
|
</p> */}
|
||||||
|
|
||||||
<p className="modal-message text-center redirect-message">
|
<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}>
|
<a href={redirectUrl} className="media-page-link" style={mediaPageLinkStyles}>
|
||||||
media page
|
media page
|
||||||
</a>
|
</a>
|
||||||
{' in '}
|
{' in '}
|
||||||
<span className="countdown">10</span> seconds.{' '}
|
<span className="countdown">10</span> seconds. Your chapters have been saved
|
||||||
{saveType === 'segments'
|
successfully.
|
||||||
? 'The new video(s) will soon be there.'
|
|
||||||
: 'Changes to the video might take a few minutes to be applied.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -13,7 +13,19 @@ interface EditorState {
|
|||||||
action?: string;
|
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
|
// Video element reference and state
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
@ -31,6 +43,9 @@ const useVideoTrimmer = () => {
|
|||||||
// Clip segments state
|
// Clip segments state
|
||||||
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
||||||
|
|
||||||
|
// Selected segment state for chapter editing
|
||||||
|
const [selectedSegmentId, setSelectedSegmentId] = useState<number | null>(null);
|
||||||
|
|
||||||
// History state for undo/redo
|
// History state for undo/redo
|
||||||
const [history, setHistory] = useState<EditorState[]>([]);
|
const [history, setHistory] = useState<EditorState[]>([]);
|
||||||
const [historyPosition, setHistoryPosition] = useState(-1);
|
const [historyPosition, setHistoryPosition] = useState(-1);
|
||||||
@ -94,31 +109,88 @@ const useVideoTrimmer = () => {
|
|||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
|
|
||||||
// Generate placeholders and create initial segment
|
// Generate placeholders and create initial segments
|
||||||
const initializeEditor = async () => {
|
const initializeEditor = async () => {
|
||||||
// Generate thumbnail for initial segment
|
let initialSegments: Segment[] = [];
|
||||||
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
|
|
||||||
|
|
||||||
// Create an initial segment that spans the entire video
|
// Check if we have existing chapters from the backend
|
||||||
const initialSegment: Segment = {
|
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [
|
||||||
id: 1,
|
{
|
||||||
name: 'segment',
|
name: 'Chapter 1',
|
||||||
startTime: 0,
|
from: '00:00:00',
|
||||||
endTime: video.duration,
|
to: '00:00:03',
|
||||||
thumbnail: segmentThumbnail,
|
},
|
||||||
};
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Initialize history state with the full-length segment
|
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);
|
||||||
|
|
||||||
|
const initialSegment: Segment = {
|
||||||
|
id: 1,
|
||||||
|
name: 'segment',
|
||||||
|
startTime: 0,
|
||||||
|
endTime: video.duration,
|
||||||
|
thumbnail: segmentThumbnail,
|
||||||
|
};
|
||||||
|
|
||||||
|
initialSegments = [initialSegment];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize history state with the segments
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: video.duration,
|
trimEnd: video.duration,
|
||||||
splitPoints: [],
|
splitPoints: [],
|
||||||
clipSegments: [initialSegment],
|
clipSegments: initialSegments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
setHistoryPosition(0);
|
setHistoryPosition(0);
|
||||||
setClipSegments([initialSegment]);
|
setClipSegments(initialSegments);
|
||||||
|
|
||||||
// Generate timeline thumbnails
|
// Generate timeline thumbnails
|
||||||
const count = 6;
|
const count = 6;
|
||||||
@ -696,99 +768,81 @@ const useVideoTrimmer = () => {
|
|||||||
setIsMuted(!isMuted);
|
setIsMuted(!isMuted);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle save action
|
// Handle updating a specific segment
|
||||||
const handleSave = () => {
|
const handleSegmentUpdate = (segmentId: number, updates: Partial<Segment>) => {
|
||||||
// Sort segments chronologically by start time before saving
|
setClipSegments((prevSegments) =>
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
prevSegments.map((segment) => (segment.id === segmentId ? { ...segment, ...updates } : segment))
|
||||||
|
);
|
||||||
// Create the JSON data for saving
|
setHasUnsavedChanges(true);
|
||||||
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
|
// Handle saving chapters to database
|
||||||
const handleSaveACopy = () => {
|
const handleChapterSave = async (chapters: { name: string; from: string; to: string }[]) => {
|
||||||
// Sort segments chronologically by start time before saving
|
try {
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
// Get media ID from window.MEDIA_DATA
|
||||||
|
const mediaId = (window as any).MEDIA_DATA?.mediaId;
|
||||||
|
if (!mediaId) {
|
||||||
|
console.error('No media ID found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the JSON data for saving as a copy
|
// Convert chapters to backend expected format
|
||||||
const saveData = {
|
const backendChapters = chapters.map((chapter) => ({
|
||||||
type: 'save_as_a_copy',
|
start: chapter.from,
|
||||||
segments: sortedSegments.map((segment) => ({
|
title: chapter.name,
|
||||||
startTime: formatDetailedTime(segment.startTime),
|
}));
|
||||||
endTime: formatDetailedTime(segment.endTime),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Display JSON in alert (for demonstration purposes)
|
// Create the API request body
|
||||||
if (process.env.NODE_ENV === 'development') {
|
const requestData = {
|
||||||
console.debug('Saving data as copy:', saveData);
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving chapters:', error);
|
||||||
|
// You might want to show a user-friendly error message here
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Helper function to get CSRF token
|
||||||
const handleSaveSegments = () => {
|
const getCsrfToken = (): string => {
|
||||||
// Sort segments chronologically by start time before saving
|
const name = 'csrftoken';
|
||||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(';').shift() || '';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
// Create the JSON data for saving individual segments
|
// Handle selected segment change
|
||||||
const saveData = {
|
const handleSelectedSegmentChange = (segmentId: number | null) => {
|
||||||
type: 'save_segments',
|
setSelectedSegmentId(segmentId);
|
||||||
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
|
// Handle seeking with mobile check
|
||||||
@ -928,6 +982,7 @@ const useVideoTrimmer = () => {
|
|||||||
splitPoints,
|
splitPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
clipSegments,
|
clipSegments,
|
||||||
|
selectedSegmentId,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
historyPosition,
|
historyPosition,
|
||||||
history,
|
history,
|
||||||
@ -941,13 +996,13 @@ const useVideoTrimmer = () => {
|
|||||||
handleRedo,
|
handleRedo,
|
||||||
handlePlaySegments,
|
handlePlaySegments,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
handleSave,
|
handleSegmentUpdate,
|
||||||
handleSaveACopy,
|
handleChapterSave,
|
||||||
handleSaveSegments,
|
handleSelectedSegmentChange,
|
||||||
isMobile,
|
isMobile,
|
||||||
videoInitialized,
|
videoInitialized,
|
||||||
setVideoInitialized,
|
setVideoInitialized,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useVideoTrimmer;
|
export default useVideoChapters;
|
||||||
@ -71,13 +71,125 @@
|
|||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
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 {
|
.clip-segments-title {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--foreground, #333);
|
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;
|
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 {
|
.segment-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -86,11 +198,17 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
transition: box-shadow 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
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 {
|
.segment-content {
|
||||||
@ -119,6 +237,16 @@
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-title {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.segment-time {
|
.segment-time {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: black;
|
color: black;
|
||||||
@ -193,4 +321,28 @@
|
|||||||
.segment-color-8 {
|
.segment-color-8 {
|
||||||
background-color: rgba(250, 204, 21, 0.15);
|
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;
|
min-width: 150px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
top: -100px !important;
|
top: -105px !important;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segment-tooltip {
|
||||||
|
top: -165px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.segment-tooltip:after,
|
.segment-tooltip:after,
|
||||||
.empty-space-tooltip:after {
|
.empty-space-tooltip:after {
|
||||||
content: "";
|
content: "";
|
||||||
@ -872,3 +876,168 @@
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
color: #3b82f6;
|
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: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// Ensure CSS file has a predictable name
|
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
|
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
|
||||||
return assetInfo.name;
|
return assetInfo.name;
|
||||||
},
|
},
|
||||||
// Add this to ensure the final bundle exposes React correctly
|
|
||||||
globals: {
|
globals: {
|
||||||
react: 'React',
|
react: 'React',
|
||||||
'react-dom': 'ReactDOM',
|
'react-dom': 'ReactDOM',
|
||||||
|
|||||||
@ -47,7 +47,7 @@ interface TimelineControlsProps {
|
|||||||
isIOSUninitialized?: boolean;
|
isIOSUninitialized?: boolean;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
onPlayPause: () => void; // Add this prop
|
onPlayPause: () => void;
|
||||||
isPlayingSegments?: boolean;
|
isPlayingSegments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user