feat: Chapters Editor (init) [copy from video editor]
15
frontend-tools/chapters-editor/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
client/public/videos/sample-video.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-37s.mp4
|
||||
videos/sample-video-37s.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-1.mp4
|
||||
client/public/videos/sample-video-10m.mp4
|
||||
client/public/videos/sample-video-10s.mp4
|
||||
0
frontend-tools/chapters-editor/.prettierignore
Normal file
5
frontend-tools/chapters-editor/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": ".prettierrc"
|
||||
}
|
||||
258
frontend-tools/chapters-editor/README.md
Normal file
@ -0,0 +1,258 @@
|
||||
# MediaCMS Chapters Editor
|
||||
|
||||
A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface.
|
||||
|
||||
## Features
|
||||
|
||||
- 📑 Create and manage video chapters with custom titles
|
||||
- ⏱️ Precise timestamp controls for chapter start and end points
|
||||
- ✂️ Split chapters and reorganize content
|
||||
- 👁️ Preview chapters with jump-to navigation
|
||||
- 🔄 Undo/redo support for all editing operations
|
||||
- 🏷️ Chapter metadata editing (titles, descriptions)
|
||||
- 💾 Save chapter data directly to MediaCMS
|
||||
- 🎯 Timeline-based chapter visualization
|
||||
- 📱 Responsive design for desktop and mobile
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Educational Content**: Add chapters to lectures and tutorials for better navigation
|
||||
- **Entertainment**: Create chapters for movies, shows, or long-form content
|
||||
- **Documentation**: Organize training videos and documentation with logical sections
|
||||
- **Accessibility**: Improve content accessibility with structured navigation
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Express (for development server)
|
||||
- Drizzle ORM
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
|
||||
- Yarn or npm package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Navigate to the Chapters Editor directory
|
||||
cd frontend-tools/chapters-editor
|
||||
|
||||
# Install dependencies with Yarn
|
||||
yarn install
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The Chapters Editor can be run in two modes:
|
||||
|
||||
### Standalone Development Mode
|
||||
|
||||
This starts a local development server with hot reloading:
|
||||
|
||||
```bash
|
||||
# Start the development server with Yarn
|
||||
yarn dev
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend-only Development Mode
|
||||
|
||||
If you want to work only on the frontend with MediaCMS backend:
|
||||
|
||||
```bash
|
||||
# Start frontend-only development with Yarn
|
||||
yarn dev:frontend
|
||||
|
||||
# Or with npm
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### For MediaCMS Integration
|
||||
|
||||
To build the Chapters Editor for integration with MediaCMS:
|
||||
|
||||
```bash
|
||||
# Build for Django integration with Yarn
|
||||
yarn build:django
|
||||
|
||||
# Or with npm
|
||||
npm run build:django
|
||||
```
|
||||
|
||||
This will compile the editor and place the output in the MediaCMS static directory.
|
||||
|
||||
### Standalone Build
|
||||
|
||||
To build the editor as a standalone application:
|
||||
|
||||
```bash
|
||||
# Build for production with Yarn
|
||||
yarn build
|
||||
|
||||
# Or with npm
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy the Chapters Editor, you can use the build and deploy script (recommended):
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
sh deploy/scripts/build_and_deploy.sh
|
||||
```
|
||||
|
||||
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
|
||||
|
||||
You can also deploy manually after building:
|
||||
|
||||
```bash
|
||||
# With Yarn
|
||||
yarn deploy
|
||||
|
||||
# Or with npm
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/client` - Frontend React application
|
||||
- `/src` - Source code
|
||||
- `/components` - React components for chapter editing
|
||||
- `/hooks` - Custom React hooks for chapter management
|
||||
- `/lib` - Utility functions and helpers
|
||||
- `/services` - API services for MediaCMS integration
|
||||
- `/styles` - CSS and style definitions
|
||||
- `/shared` - Shared TypeScript types and utilities
|
||||
|
||||
## API Integration
|
||||
|
||||
The Chapters Editor interfaces with MediaCMS through a set of API endpoints for:
|
||||
|
||||
- Retrieving video metadata and existing chapters
|
||||
- Saving chapter data (timestamps, titles, descriptions)
|
||||
- Validating chapter structure and timing
|
||||
- Integration with MediaCMS user permissions
|
||||
|
||||
### Chapter Data Format
|
||||
|
||||
Chapters are stored in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"chapters": [
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "Introduction",
|
||||
"startTime": 0,
|
||||
"endTime": 120,
|
||||
"description": "Opening remarks and overview"
|
||||
},
|
||||
{
|
||||
"id": "chapter-2",
|
||||
"title": "Main Content",
|
||||
"startTime": 120,
|
||||
"endTime": 600,
|
||||
"description": "Core educational material"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Code Formatting
|
||||
|
||||
To automatically format all source files using [Prettier](https://prettier.io):
|
||||
|
||||
```bash
|
||||
# Format all code in the src directory
|
||||
npx prettier --write client/src/
|
||||
|
||||
# Or format specific file types
|
||||
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
|
||||
```
|
||||
|
||||
You can also add this as a script in `package.json`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"format": "prettier --write client/src/"
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite to ensure Chapters Editor functionality:
|
||||
|
||||
```bash
|
||||
# Run tests with Yarn
|
||||
yarn test
|
||||
|
||||
# Or with npm
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
yarn test:watch
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/chapter-enhancement`
|
||||
3. Make your changes and add tests
|
||||
4. Run the formatter: `yarn format`
|
||||
5. Run tests: `yarn test`
|
||||
6. Commit your changes: `git commit -m "Add chapter enhancement"`
|
||||
7. Push to the branch: `git push origin feature/chapter-enhancement`
|
||||
8. Submit a pull request
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions.
|
||||
|
||||
**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded.
|
||||
|
||||
**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug mode for detailed logging:
|
||||
|
||||
```bash
|
||||
# Start with debug logging
|
||||
DEBUG=true yarn dev
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Chromium 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details.
|
||||
34
frontend-tools/chapters-editor/client/index.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Chapters Editor</title>
|
||||
<!-- Add meta tag to help iOS devices render as desktop -->
|
||||
<script>
|
||||
// Try to detect iOS
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (isIOS) {
|
||||
// Replace viewport meta tag with one optimized for desktop view
|
||||
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (viewportMeta) {
|
||||
viewportMeta.setAttribute(
|
||||
'content',
|
||||
'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
|
||||
);
|
||||
}
|
||||
|
||||
// Add a class to the HTML element for iOS-specific styles
|
||||
document.documentElement.classList.add('ios-device');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chapters-editor-root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
303
frontend-tools/chapters-editor/client/src/App.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
||||
import logger from "./lib/logger";
|
||||
import VideoPlayer from "@/components/VideoPlayer";
|
||||
import TimelineControls from "@/components/TimelineControls";
|
||||
import EditingTools from "@/components/EditingTools";
|
||||
import ClipSegments from "@/components/ClipSegments";
|
||||
import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
isPlayingSegments,
|
||||
handlePlaySegments
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Function to play from the beginning
|
||||
const playFromBeginning = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
handleMobileSafeSeek(0);
|
||||
if (!isPlaying) {
|
||||
handlePlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
const newTime = Math.max(0, currentTime - 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
const newTime = Math.min(duration, currentTime + 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
// Sort segments by start time to ensure correct order
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// 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));
|
||||
|
||||
// Check if we're inside the segment
|
||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||
return true;
|
||||
}
|
||||
// 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
|
||||
if (!currentSegment) {
|
||||
nextSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
return segStartTime > currentPosition;
|
||||
});
|
||||
}
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
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));
|
||||
}
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||
|
||||
// If we've reached or passed the boundary
|
||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
|
||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(actualPosition),
|
||||
difference: difference
|
||||
});
|
||||
|
||||
// If we're not exactly at the target position, try one more time
|
||||
if (difference > 0) {
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Multiple attempts to ensure precision, with increasing delays
|
||||
setExactPosition();
|
||||
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
|
||||
video.removeEventListener("timeupdate", checkBoundary);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Log the final position for debugging
|
||||
logger.debug("Stopped at position:", {
|
||||
target: formatDetailedTime(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;
|
||||
}
|
||||
};
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener("timeupdate", checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment
|
||||
? {
|
||||
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 (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={handlePlay}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
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;
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 813 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(30, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(28, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,83 @@
|
||||
import { formatTime, formatLongTime } from '@/lib/timeUtils';
|
||||
import '../styles/ClipSegments.css';
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface ClipSegmentsProps {
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
// Sort segments by startTime
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Handle delete segment click
|
||||
const handleDeleteSegment = (segmentId: number) => {
|
||||
// Create and dispatch the delete event
|
||||
const deleteEvent = new CustomEvent('delete-segment', {
|
||||
detail: { segmentId },
|
||||
});
|
||||
document.dispatchEvent(deleteEvent);
|
||||
};
|
||||
|
||||
// Generate the same color background for a segment as shown in the timeline
|
||||
const getSegmentColorClass = (index: number) => {
|
||||
// Return CSS class based on index modulo 8
|
||||
// This matches the CSS nth-child selectors in the timeline
|
||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
||||
<div className="segment-content">
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||
></div>
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">Segment {index + 1}</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
<div className="segment-duration">
|
||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipSegments;
|
||||
@ -0,0 +1,272 @@
|
||||
import '../styles/EditingTools.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPlaySegments: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPlaying?: boolean;
|
||||
isPlayingSegments?: boolean;
|
||||
}
|
||||
|
||||
const EditingTools = ({
|
||||
onSplit,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaySegments,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPlaying = false,
|
||||
isPlayingSegments = false,
|
||||
}: EditingToolsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640);
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
return () => window.removeEventListener('resize', checkScreenSize);
|
||||
}, []);
|
||||
|
||||
// Handle play button click with iOS fix
|
||||
const handlePlay = () => {
|
||||
// Ensure lastSeekedPosition is used when play is clicked
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
onPlay();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editing-tools-container">
|
||||
<div className="flex-container single-row">
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
{/* Play Segments button */}
|
||||
<button
|
||||
className={`button segments-button`}
|
||||
onClick={onPlaySegments}
|
||||
data-tooltip={
|
||||
isPlayingSegments ? 'Stop segments playback' : 'Play segments in one continuous flow'
|
||||
}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPlayingSegments ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop Preview</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Play Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Play Preview button */}
|
||||
{/* <button
|
||||
className="button preview-button"
|
||||
onClick={onPreview}
|
||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Standard Play button (only shown when not in segments playback on small screens) */}
|
||||
{(!isPlayingSegments || !isSmallScreen) && (
|
||||
<button
|
||||
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
|
||||
onClick={handlePlay}
|
||||
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
{isPlaying && !isPlayingSegments ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Pause</span>
|
||||
<span className="short-text">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play</span>
|
||||
<span className="short-text">Play</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||
{/* {isPlayingSegments && !isSmallScreen && (
|
||||
<div className="segments-playback-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{/* {isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Undo"
|
||||
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'}
|
||||
disabled={!canUndo || isPlayingSegments}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Redo"
|
||||
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Redo last undone action'}
|
||||
disabled={!canRedo || isPlayingSegments}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Reset to full video'}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingTools;
|
||||
@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import '../styles/IOSPlayPrompt.css';
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if the device is mobile
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
const isMobile = checkIsMobile();
|
||||
setIsVisible(isMobile);
|
||||
}, []);
|
||||
|
||||
// Close the prompt when video plays
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Just close the prompt when video plays
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
const handlePlayClick = () => {
|
||||
onPlay();
|
||||
// Prompt will be closed by the play event handler
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
{/* <h3>Mobile Device Notice</h3>
|
||||
|
||||
<p>
|
||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
||||
using the timeline controls.
|
||||
</p>
|
||||
|
||||
<div className="mobile-prompt-instructions">
|
||||
<p>Please follow these steps:</p>
|
||||
<ol>
|
||||
<li>Tap the button below to start the video</li>
|
||||
<li>After the video starts, you can pause it</li>
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
@ -0,0 +1,184 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { formatTime } from '@/lib/timeUtils';
|
||||
import '../styles/IOSVideoPlayer.css';
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clean up intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get the video source URL from the main player
|
||||
useEffect(() => {
|
||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||
if (source && source.src) {
|
||||
setVideoUrl(source.src);
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
setVideoUrl('/videos/sample-video.mp4');
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms increment when button is held
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous increment
|
||||
const stopIncrement = () => {
|
||||
if (incrementIntervalRef.current) {
|
||||
clearInterval(incrementIntervalRef.current);
|
||||
incrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms decrement when button is held
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous decrement
|
||||
const stopDecrement = () => {
|
||||
if (decrementIntervalRef.current) {
|
||||
clearInterval(decrementIntervalRef.current);
|
||||
decrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ios-video-player-container">
|
||||
{/* Current Time / Duration Display */}
|
||||
<div className="ios-time-display mb-2">
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={jumpBackward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
-15s
|
||||
</button>
|
||||
<button
|
||||
onClick={jumpForward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
+15s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* iOS Fine Control Buttons */}
|
||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||
<button
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
onMouseLeave={stopDecrement}
|
||||
onTouchEnd={stopDecrement}
|
||||
onTouchCancel={stopDecrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
onMouseLeave={stopIncrement}
|
||||
onTouchEnd={stopIncrement}
|
||||
onTouchCancel={stopIncrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
+50ms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ios-note mt-2 text-xs text-gray-500">
|
||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
@ -0,0 +1,74 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import '../styles/Modal.css';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
// Close modal when Escape key is pressed
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Handle click outside the modal content to close it
|
||||
const handleClickOutside = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@ -0,0 +1,469 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
||||
import logger from '../lib/logger';
|
||||
import '../styles/VideoPlayer.css';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isMuted?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isMuted = false,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({
|
||||
x: 0,
|
||||
});
|
||||
const [tooltipTime, setTooltipTime] = useState(0);
|
||||
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
||||
|
||||
// Detect iOS device
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasInitialized]);
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
video.setAttribute('x-webkit-airplay', 'allow');
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug('Video play event fired');
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem('video_initialized', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug('Video pause event fired');
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
setLastPosition(currentTime);
|
||||
}, [currentTime]);
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Jump 10 seconds backward
|
||||
const handleBackward = () => {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle start of progress bar dragging
|
||||
const handleProgressDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position
|
||||
handleProgressDrag(e);
|
||||
|
||||
// Set up document-level event listeners for mouse movement and release
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressDrag(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle progress dragging for both mouse and touch events
|
||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({
|
||||
x: e.clientX,
|
||||
});
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle touch events for progress bar
|
||||
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||
if (!progressRef.current || !e.touches[0]) return;
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position using touch
|
||||
handleProgressTouchMove(e);
|
||||
|
||||
// Set up document-level event listeners for touch movement and release
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressTouchMove(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
// Handle touch dragging on progress bar
|
||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
// Get the touch coordinates
|
||||
const touch = 'touches' in e ? e.touches[0] : null;
|
||||
if (!touch) return;
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * touchPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({
|
||||
x: touch.clientX,
|
||||
});
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position for iOS Safari
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle click on progress bar (for non-drag interactions)
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we're already dragging, don't handle the click
|
||||
if (isDraggingProgress) return;
|
||||
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling fullscreen
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on video to play/pause
|
||||
const handleVideoClick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// If the video is paused, we want to play it
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
logger.debug('iOS: Explicitly setting position before play:', lastPosition);
|
||||
|
||||
// First, seek to the position
|
||||
video.currentTime = lastPosition;
|
||||
|
||||
// Use a small timeout to ensure seeking is complete before play
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug(
|
||||
'iOS: Play started successfully at position:',
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('iOS: Error playing video:', err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug('Normal: Play started successfully');
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error playing video:', err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If playing, pause and update state
|
||||
video.pause();
|
||||
onPlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
controls={false}
|
||||
muted={isMuted}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
{isIOS && !hasInitialized && !isPlaying && (
|
||||
<div className="ios-first-play-indicator">
|
||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
<div className="video-time-display">
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressDragStart}
|
||||
onTouchStart={handleProgressTouchStart}
|
||||
>
|
||||
<div
|
||||
className="video-progress-fill"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="video-scrubber"
|
||||
style={{
|
||||
left: `${progressPercentage}%`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<div
|
||||
className="video-time-tooltip"
|
||||
style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{formatDetailedTime(tooltipTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@ -0,0 +1,953 @@
|
||||
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);
|
||||
|
||||
// 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 = () => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Play immediately without delay
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true); // Update state to reflect we're playing
|
||||
})
|
||||
.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]);
|
||||
|
||||
// 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 (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);
|
||||
|
||||
// Start segments playback
|
||||
|
||||
// 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,
|
||||
isPlayingSegments,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePlaySegments,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoTrimmer;
|
||||
805
frontend-tools/chapters-editor/client/src/index.css
Normal file
@ -0,0 +1,805 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--primary: 207 90% 54%;
|
||||
--primary-foreground: 211 100% 99%;
|
||||
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Player Styles */
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-current-time {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: hsl(var(--primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
/* Play/Pause indicator for video player */
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Only show play/pause indicator on hover */
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Timeline Styles */
|
||||
.timeline-scroll-container {
|
||||
height: 6rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
height: calc(100% + 10px);
|
||||
width: 2px;
|
||||
background-color: red;
|
||||
z-index: 100; /* Highest z-index to stay on top of everything */
|
||||
pointer-events: none; /* Allow clicks to pass through to segments underneath */
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
width: 8px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: ew-resize;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.trim-handle.left {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.trim-handle.right {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.timeline-thumbnail {
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.split-point {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Clip Segment styles */
|
||||
.clip-segment {
|
||||
position: absolute;
|
||||
height: 95%;
|
||||
top: 0;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-blend-mode: soft-light;
|
||||
/* Border is now set in the color-specific rules */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.1s;
|
||||
/* Original z-index for stacking order based on segment ID */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* No background colors for segments, just borders with 2-color scheme */
|
||||
.clip-segment:nth-child(odd),
|
||||
.segment-color-1,
|
||||
.segment-color-3,
|
||||
.segment-color-5,
|
||||
.segment-color-7 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
|
||||
}
|
||||
.clip-segment:nth-child(even),
|
||||
.segment-color-2,
|
||||
.segment-color-4,
|
||||
.segment-color-6,
|
||||
.segment-color-8 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
|
||||
.clip-segment:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.clip-segment:active {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.clip-segment.selected {
|
||||
border-width: 3px; /* Make border thicker instead of changing color */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 25;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.clip-segment-info {
|
||||
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
|
||||
color: #000000; /* Pure black text */
|
||||
padding: 6px 8px;
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-name {
|
||||
font-weight: bold;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-time {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-duration {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
background: rgba(179, 217, 255, 0.4); /* Light blue background */
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.clip-segment-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
cursor: ew-resize;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-segment-handle::after {
|
||||
content: "↔";
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.clip-segment-handle.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle:hover {
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* Zoom Slider */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip]:hover::before,
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips (simple hover labels) on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]::before,
|
||||
[data-tooltip]::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for buttons with disabled state */
|
||||
button[disabled][data-tooltip]::before,
|
||||
button[disabled][data-tooltip]::after {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Custom tooltip for action buttons - completely different approach */
|
||||
.tooltip-action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before,
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
/* Reset standard tooltip styles first */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Position below the button */
|
||||
bottom: -35px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
content: "";
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
|
||||
|
||||
/* Position the arrow */
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tooltip-action-btn:hover[data-tooltip]::before,
|
||||
.tooltip-action-btn:hover[data-tooltip]::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure tooltip container has proper space */
|
||||
|
||||
/* Segment tooltip styles */
|
||||
.segment-tooltip {
|
||||
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
color: #000000; /* Pure black text */
|
||||
border-radius: 4px;
|
||||
padding: 6px; /* Regular padding now that we have custom tooltips */
|
||||
min-width: 140px; /* Increased width to accommodate the new delete button */
|
||||
z-index: 1000; /* Increased z-index */
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Adjust for the hand icons specifically */
|
||||
.tooltip-action-btn.set-in svg,
|
||||
.tooltip-action-btn.set-out svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
/* Empty space tooltip styling */
|
||||
.empty-space-tooltip {
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px;
|
||||
z-index: 50;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-space-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: white transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
width: auto;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tooltip-btn-text {
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.icon-new-segment {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Zoom dropdown styling */
|
||||
.zoom-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.zoom-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
.zoom-dropdown {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.zoom-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.zoom-option:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.zoom-option.selected {
|
||||
background-color: rgba(0, 123, 255, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
.save-copy-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
}
|
||||
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Time navigation input styling */
|
||||
.time-nav-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
width: 150px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.time-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Timeline navigation and zoom controls responsiveness */
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller screens */
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Media queries for responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.timeline-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timeline header styling */
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.timeline-title-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.current-time,
|
||||
.duration-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timeline-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.save-copy-button {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-dropdown-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
31
frontend-tools/chapters-editor/client/src/lib/logger.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A consistent logger utility that only outputs debug messages in development
|
||||
* but always shows errors, warnings, and info messages.
|
||||
*/
|
||||
const logger = {
|
||||
/**
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Always logs error messages
|
||||
*/
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
|
||||
/**
|
||||
* Always logs warning messages
|
||||
*/
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
|
||||
/**
|
||||
* Always logs info messages
|
||||
*/
|
||||
info: (...args: any[]) => console.info(...args),
|
||||
};
|
||||
|
||||
export default logger;
|
||||
51
frontend-tools/chapters-editor/client/src/lib/queryClient.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { QueryClient, QueryFunction } from '@tanstack/react-query';
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { 'Content-Type': 'application/json' } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = 'returnNull' | 'throw';
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: 'throw' }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
34
frontend-tools/chapters-editor/client/src/lib/timeUtils.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||
*/
|
||||
export const formatDetailedTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return '00:00:00.000';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||
|
||||
const formattedHours = String(hours).padStart(2, '0');
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
|
||||
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||
*/
|
||||
export const formatTime = (seconds: number): string => {
|
||||
// Use the detailed format instead of the old MM:SS format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||
*/
|
||||
export const formatLongTime = (seconds: number): string => {
|
||||
// Use the detailed format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
6
frontend-tools/chapters-editor/client/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
44
frontend-tools/chapters-editor/client/src/lib/videoUtils.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Generate a solid color background for a segment
|
||||
* Returns a CSS color based on the segment position
|
||||
*/
|
||||
export const generateSolidColor = (time: number, duration: number): string => {
|
||||
// Use the time position to create different colors
|
||||
// This gives each segment a different color without needing an image
|
||||
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
||||
|
||||
// Calculate color based on position
|
||||
// Use an extremely light blue-based color palette
|
||||
const hue = 210; // Blue base
|
||||
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
||||
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy function kept for compatibility
|
||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||
*/
|
||||
export const generateThumbnail = async (videoElement: HTMLVideoElement, time: number): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// Create a small canvas for the solid color
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 10; // Much smaller - we only need a color
|
||||
canvas.height = 10;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Get the solid color based on time
|
||||
const color = generateSolidColor(time, videoElement.duration);
|
||||
|
||||
// Fill with solid color
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Convert to data URL (much smaller now)
|
||||
const dataUrl = canvas.toDataURL('image/png', 0.5);
|
||||
resolve(dataUrl);
|
||||
});
|
||||
};
|
||||
37
frontend-tools/chapters-editor/client/src/main.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MEDIA_DATA = {
|
||||
videoUrl: '',
|
||||
mediaId: '',
|
||||
};
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MEDIA_DATA: {
|
||||
videoUrl: string;
|
||||
mediaId: string;
|
||||
};
|
||||
seekToFunction?: (time: number) => void;
|
||||
lastSeekedPosition: number;
|
||||
}
|
||||
}
|
||||
|
||||
// Mount the components when the DOM is ready
|
||||
const mountComponents = () => {
|
||||
const chaptersEditorContainer = document.getElementById('chapters-editor-root');
|
||||
if (chaptersEditorContainer) {
|
||||
const chaptersEditorRoot = createRoot(chaptersEditorContainer);
|
||||
chaptersEditorRoot.render(<App />);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
115
frontend-tools/chapters-editor/client/src/services/videoApi.ts
Normal file
@ -0,0 +1,115 @@
|
||||
// API service for video trimming operations
|
||||
|
||||
interface TrimVideoRequest {
|
||||
segments: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
name?: string;
|
||||
}[];
|
||||
saveAsCopy?: boolean;
|
||||
saveIndividualSegments?: boolean;
|
||||
}
|
||||
|
||||
interface TrimVideoResponse {
|
||||
msg: string;
|
||||
url_redirect: string;
|
||||
status?: number; // HTTP status code for success/error
|
||||
error?: string; // Error message if status is not 200
|
||||
}
|
||||
|
||||
// Helper function to simulate delay
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// For now, we'll use a mock API that returns a promise
|
||||
// This can be replaced with actual API calls later
|
||||
export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promise<TrimVideoResponse> => {
|
||||
try {
|
||||
// Attempt the real API call
|
||||
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// For error responses, return with error status and message
|
||||
if (response.status === 400) {
|
||||
// Handle 400 Bad Request - return with error details
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: 400,
|
||||
error: errorData.error || 'An error occurred during processing',
|
||||
msg: 'Video Processing Error',
|
||||
url_redirect: '',
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: 400,
|
||||
error: 'An error occurred during video processing',
|
||||
msg: 'Video Processing Error',
|
||||
url_redirect: '',
|
||||
};
|
||||
}
|
||||
} else if (response.status !== 404) {
|
||||
// Handle other error responses
|
||||
try {
|
||||
// Try to get error details from response
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
error: errorData.error || 'An error occurred during processing',
|
||||
msg: 'Video Processing Error',
|
||||
url_redirect: '',
|
||||
};
|
||||
} catch (parseError) {
|
||||
// If can't parse response JSON, return generic error
|
||||
return {
|
||||
status: response.status,
|
||||
error: 'An error occurred during video processing',
|
||||
msg: 'Video Processing Error',
|
||||
url_redirect: '',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// If endpoint not ready (404), return mock success response
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: 'Video Processed Successfully', // Updated per requirements
|
||||
url_redirect: `./view?m=${mediaId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response
|
||||
const jsonResponse = await response.json();
|
||||
return {
|
||||
status: 200,
|
||||
msg: 'Video Processed Successfully', // Ensure the success message is correct
|
||||
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
|
||||
...jsonResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
// For any fetch errors, return mock success response with delay
|
||||
await delay(1500); // Simulate 1.5 second server delay
|
||||
return {
|
||||
status: 200, // Mock success status
|
||||
msg: 'Video Processed Successfully', // Consistent with requirements
|
||||
url_redirect: `./view?m=${mediaId}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* Mock implementation that simulates network latency
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
msg: "Video is processing for trim",
|
||||
url_redirect: `./view?m=${mediaId}`
|
||||
});
|
||||
}, 1500); // Simulate 1.5 second server delay
|
||||
});
|
||||
*/
|
||||
};
|
||||
@ -0,0 +1,196 @@
|
||||
#chapters-editor-root {
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
.clip-segments-container {
|
||||
margin-top: 1rem;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.clip-segments-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground, #333);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.segment-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.segment-thumbnail {
|
||||
width: 4rem;
|
||||
height: 2.25rem;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.75rem;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.segment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.segment-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-time {
|
||||
font-size: 0.75rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-duration {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
display: inline-block;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.segment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 0.375rem;
|
||||
color: #4b5563;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
min-width: auto;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: rgba(51, 51, 51, 0.7);
|
||||
}
|
||||
|
||||
.segment-color-1 {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.segment-color-2 {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
.segment-color-3 {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.segment-color-4 {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.segment-color-5 {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
.segment-color-6 {
|
||||
background-color: rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
.segment-color-7 {
|
||||
background-color: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
.segment-color-8 {
|
||||
background-color: rgba(250, 204, 21, 0.15);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,397 @@
|
||||
#chapters-editor-root {
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.editing-tools-container {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Show full text on larger screens, hide short text */
|
||||
.full-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reset text always visible by default */
|
||||
.reset-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.play-buttons-group {
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto; /* Don't expand to fill space */
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto; /* Push to right edge */
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-right: 1px solid #d1d5db;
|
||||
height: 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Style for play buttons with highlight effect */
|
||||
.play-button,
|
||||
.preview-button {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
|
||||
/* Greyed out play button when segments are playing */
|
||||
.play-button.greyed-out {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Highlighted stop button with blue pulse on small screens */
|
||||
.segments-button.highlighted-stop {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
animation: bluePulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bluePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Completely disable ALL hover effects for play buttons */
|
||||
.play-button:hover:not(:disabled),
|
||||
.preview-button:hover:not(:disabled) {
|
||||
/* Reset everything to prevent any changes */
|
||||
color: inherit !important;
|
||||
transform: none !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: auto !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.play-button svg,
|
||||
.preview-button svg {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
/* Make sure SVG scales with the button but doesn't change layout */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add responsive button text class */
|
||||
.button-text {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Media queries for the editing tools */
|
||||
@media (max-width: 992px) {
|
||||
/* Hide text for undo/redo buttons on medium screens */
|
||||
.button-group.secondary .button-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Keep all buttons in a single row, make them more compact */
|
||||
.flex-container.single-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Keep font size consistent regardless of screen size */
|
||||
.preview-button,
|
||||
.play-button {
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Prevent container overflow on mobile */
|
||||
.editing-tools-container {
|
||||
padding: 0.75rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* At this breakpoint, make preview button text shorter */
|
||||
.preview-button {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Switch to short text versions */
|
||||
.full-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: inline;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
/* Hide reset text */
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure buttons stay in correct position */
|
||||
.button-group.play-buttons-group {
|
||||
flex: initial;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-group.secondary {
|
||||
flex: initial;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Reduce button sizes on mobile */
|
||||
.button-group button {
|
||||
padding: 0.375rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.button-group button svg {
|
||||
height: 1.125rem;
|
||||
width: 1.125rem;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
/* Keep single row, left-align play buttons, right-align controls */
|
||||
.flex-container.single-row {
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Fix left-align for play buttons */
|
||||
.button-group.play-buttons-group {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Fix right-align for editing controls */
|
||||
.button-group.secondary {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Reduce button padding to fit more easily */
|
||||
.button-group button {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens - maintain layout but reduce further */
|
||||
@media (max-width: 480px) {
|
||||
.editing-tools-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-group.play-buttons-group,
|
||||
.button-group.secondary {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none; /* Hide divider on very small screens */
|
||||
}
|
||||
|
||||
/* Even smaller buttons on very small screens */
|
||||
.button-group button {
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
.button-group button svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Hide all button text on very small screens */
|
||||
.button-text,
|
||||
.reset-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Portrait orientation specific fixes */
|
||||
@media (max-width: 640px) and (orientation: portrait) {
|
||||
.editing-tools-container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flex-container.single-row {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Ensure button groups don't overflow */
|
||||
.button-group {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.button-group.play-buttons-group {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.button-group.secondary {
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
.ios-notification {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background-color: #fffdeb;
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 10px;
|
||||
animation: slide-down 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ios-notification-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ios-notification-icon {
|
||||
flex-shrink: 0;
|
||||
color: #0066cc;
|
||||
margin-right: 15px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.ios-notification-message {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ios-notification-message h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.ios-notification-message li {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ios-notification-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ios-notification-close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Desktop mode button styling */
|
||||
.ios-mode-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.ios-desktop-mode-btn:active {
|
||||
background-color: #004499;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ios-or {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0 0 6px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.ios-notification {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.ios-notification-close {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make sure this notification has better visibility on smaller screens */
|
||||
@media (max-width: 480px) {
|
||||
.ios-notification-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.ios-notification-message h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ios-notification-message p,
|
||||
.ios-notification-message ol {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add iOS-specific styles when in desktop mode */
|
||||
html.ios-device {
|
||||
/* Force the content to be rendered at desktop width */
|
||||
min-width: 1024px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
html.ios-device .ios-control-btn {
|
||||
/* Make buttons easier to tap in desktop mode */
|
||||
min-height: 44px;
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
.mobile-play-prompt-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.mobile-play-prompt {
|
||||
background-color: white;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-play-prompt h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-play-prompt p {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mobile-prompt-instructions {
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-prompt-instructions p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-prompt-instructions ol {
|
||||
margin: 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.mobile-prompt-instructions li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mobile-play-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 5px;
|
||||
/* Make button easier to tap on mobile */
|
||||
min-height: 44px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.mobile-play-button:hover {
|
||||
background-color: #0069d9;
|
||||
}
|
||||
|
||||
.mobile-play-button:active {
|
||||
background-color: #0062cc;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Special styles for mobile devices */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.mobile-play-button {
|
||||
/* Extra spacing for mobile */
|
||||
padding: 14px 25px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
.ios-video-player-container {
|
||||
position: relative;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ios-video-player-container video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 16/9;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.ios-time-display {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ios-note {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* iOS-specific styling tweaks */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.ios-video-player-container video {
|
||||
max-height: 50vh; /* Use viewport height on iOS */
|
||||
}
|
||||
|
||||
/* Improve controls visibility on iOS */
|
||||
video::-webkit-media-controls {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure controls don't disappear too quickly */
|
||||
video::-webkit-media-controls-panel {
|
||||
transition-duration: 3s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* External controls styling */
|
||||
.ios-external-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ios-control-btn {
|
||||
font-weight: bold;
|
||||
min-width: 100px;
|
||||
height: 44px; /* Minimum touch target size for iOS */
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
|
||||
}
|
||||
|
||||
.ios-control-btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Prevent text selection on buttons */
|
||||
.no-select {
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Specifically prevent default behavior on fine controls */
|
||||
.ios-fine-controls button,
|
||||
.ios-external-controls .no-select {
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
306
frontend-tools/chapters-editor/client/src/styles/Modal.css
Normal file
@ -0,0 +1,306 @@
|
||||
#chapters-editor-root {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modal-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.modal-button-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-danger:hover {
|
||||
background-color: #bd2130;
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #0066cc;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #28a745;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #4caf50;
|
||||
animation: success-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #dc3545;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
color: #f44336;
|
||||
animation: error-pop 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes error-pop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: #0066cc;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0055aa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #3d8b40;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.centered-choice:hover {
|
||||
background-color: #0055aa;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #f44336;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.redirect-message {
|
||||
margin-top: 20px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,874 @@
|
||||
#chapters-editor-root {
|
||||
.timeline-container-card {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground, #333);
|
||||
}
|
||||
|
||||
.timeline-title-text {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground, #333);
|
||||
}
|
||||
|
||||
.time-code {
|
||||
font-family: monospace;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.duration-time {
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground, #333);
|
||||
}
|
||||
|
||||
.timeline-scroll-container {
|
||||
position: relative;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
background-color: #fafbfc;
|
||||
height: 70px;
|
||||
border-radius: 0.25rem;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
height: 82px; /* Increased height to extend below timeline */
|
||||
width: 2px;
|
||||
background-color: #000;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-marker-head {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.timeline-marker-drag {
|
||||
position: absolute;
|
||||
bottom: -12px; /* Changed from -6px to -12px to move it further down */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #4b5563;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.timeline-marker-drag.dragging {
|
||||
cursor: grabbing;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.timeline-marker-head-icon {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.timeline-marker-drag-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
transform: rotate(90deg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 20px;
|
||||
background-color: black;
|
||||
cursor: ew-resize;
|
||||
|
||||
&.left {
|
||||
right: 0;
|
||||
top: 10px;
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
&.right {
|
||||
left: 0;
|
||||
top: 10px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-thumbnail {
|
||||
display: inline-block;
|
||||
height: 70px;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.split-point {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: rgba(255, 0, 0, 0.5);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.clip-segment {
|
||||
position: absolute;
|
||||
height: 70px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(0, 0, 0, 0.4);
|
||||
background-color: rgba(240, 240, 240, 0.8) !important;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.7);
|
||||
border-color: rgba(59, 130, 246, 0.9);
|
||||
}
|
||||
|
||||
&.selected:hover {
|
||||
background-color: rgba(240, 248, 255, 0.85) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-segment-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.4rem;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
transition: background-color 0.2s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.clip-segment:hover .clip-segment-info {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.clip-segment.selected .clip-segment-info {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.clip-segment.selected:hover .clip-segment-info {
|
||||
background-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.clip-segment-name {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clip-segment-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.clip-segment-duration {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.clip-segment-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.clip-segment-handle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.clip-segment-handle.left {
|
||||
left: 0;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.clip-segment-handle.right {
|
||||
right: 0;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Enhanced handles for touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.clip-segment-handle {
|
||||
width: 14px; /* Wider target for touch devices */
|
||||
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
|
||||
}
|
||||
|
||||
.clip-segment-handle:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.clip-segment-handle.left:after {
|
||||
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.clip-segment-handle.right:after {
|
||||
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Active state for touch feedback */
|
||||
.clip-segment-handle:active {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
height: 85px;
|
||||
}
|
||||
|
||||
.timeline-marker-head {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: -13px;
|
||||
}
|
||||
|
||||
.timeline-marker-drag {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
bottom: -18px;
|
||||
}
|
||||
|
||||
.timeline-marker-head.dragging {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: -15px;
|
||||
}
|
||||
}
|
||||
|
||||
.segment-tooltip,
|
||||
.empty-space-tooltip {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
top: -100px !important;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.segment-tooltip:after,
|
||||
.empty-space-tooltip:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid white;
|
||||
}
|
||||
|
||||
.segment-tooltip:before,
|
||||
.empty-space-tooltip:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.delete:hover {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment .tooltip-btn-text {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.time-navigation {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.time-nav-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
width: 8rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.time-button-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.time-button {
|
||||
background-color: #e5e7eb;
|
||||
color: black;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.time-button:first-child {
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.time-button:last-child {
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.zoom-dropdown-container {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
background-color: #374151;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-button:hover {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.zoom-button svg {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.zoom-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 0.25rem;
|
||||
width: 9rem;
|
||||
background-color: #374151;
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.zoom-option {
|
||||
padding: 0.25rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-option:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.zoom-option.selected {
|
||||
background-color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zoom-option svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Save buttons container */
|
||||
.save-buttons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* General styles for all save buttons */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
color: #ffffff;
|
||||
background: #0066cc;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Shared hover effect */
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover,
|
||||
.save-segments-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Media query for smaller screens */
|
||||
@media (max-width: 576px) {
|
||||
.save-buttons-row {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
flex: 1;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens - adjust save buttons */
|
||||
@media (max-width: 480px) {
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
font-size: 0.675rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Remove margins for controls-right buttons */
|
||||
.controls-right {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls-right button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal success and error styling */
|
||||
.modal-success-content,
|
||||
.modal-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-success-icon,
|
||||
.modal-error-icon {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-success-icon svg {
|
||||
color: #4caf50;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-error-icon svg {
|
||||
color: #f44336;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.success-link {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.success-link:hover {
|
||||
background-color: #388e3c;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Modal spinner animation */
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: #0066cc;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Centered modal content */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-choice-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.modal-choice-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.modal-choice-button svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.centered-choice {
|
||||
margin: 0 auto;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Timeline Overlay */
|
||||
.mobile-timeline-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none; /* Allow clicks to pass through */
|
||||
}
|
||||
|
||||
.mobile-timeline-message {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 15px 25px;
|
||||
text-align: center;
|
||||
max-width: 80%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.mobile-timeline-message p {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
margin: 0 0 15px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-play-icon {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 15px solid transparent;
|
||||
border-bottom: 15px solid transparent;
|
||||
border-left: 25px solid white;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Segments playback mode styles - minimal functional styling */
|
||||
.segments-playback-mode .tooltip-time-btn {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segments-playback-mode .tooltip-action-btn.play,
|
||||
.segments-playback-mode .tooltip-action-btn.pause {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* During segments playback mode, disable button interactions but keep hover working */
|
||||
.segments-playback-mode .tooltip-time-btn[disabled],
|
||||
.segments-playback-mode .tooltip-action-btn[disabled] {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Ensure disabled buttons still show tooltips on hover */
|
||||
.segments-playback-mode [data-tooltip][disabled]:hover:before,
|
||||
.segments-playback-mode [data-tooltip][disabled]:hover:after {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Show segments playback message */
|
||||
.segments-playback-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.segments-playback-message svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
@ -0,0 +1,340 @@
|
||||
.two-row-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: white;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
z-index: 3000; /* Highest z-index to ensure it's above all other elements */
|
||||
}
|
||||
|
||||
/* Hide ±100ms buttons for more compact tooltip */
|
||||
.tooltip-time-btn[data-tooltip="Decrease by 100ms"],
|
||||
.tooltip-time-btn[data-tooltip="Increase by 100ms"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tooltip-row:first-child {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tooltip-time-btn {
|
||||
background-color: #f0f0f0 !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 500 !important;
|
||||
color: #333 !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.2s !important;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-time-btn:hover {
|
||||
background-color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.tooltip-time-display {
|
||||
font-family: monospace !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: #333 !important;
|
||||
padding: 4px 6px !important;
|
||||
background-color: #f7f7f7 !important;
|
||||
border-radius: 4px !important;
|
||||
min-width: 100px !important;
|
||||
text-align: center !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Disabled state for time display */
|
||||
.tooltip-time-display.disabled {
|
||||
pointer-events: none !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.6 !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
/* Force disabled tooltips to show on hover for better user feedback */
|
||||
.tooltip-time-btn.disabled[data-tooltip]:hover:before,
|
||||
.tooltip-time-btn.disabled[data-tooltip]:hover:after,
|
||||
.tooltip-action-btn.disabled[data-tooltip]:hover:before,
|
||||
.tooltip-action-btn.disabled[data-tooltip]:hover:after {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
min-width: 20px !important;
|
||||
position: relative; /* Add relative positioning for tooltips */
|
||||
}
|
||||
|
||||
/* Custom tooltip styles for second row action buttons - positioned below */
|
||||
.tooltip-action-btn[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
height: 30px;
|
||||
top: 35px; /* Position below the button with increased space */
|
||||
left: 50%; /* Center horizontally */
|
||||
transform: translateX(-50%); /* Center horizontally */
|
||||
margin-left: 0; /* Reset margin */
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
text-align: left;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Triangle arrow pointing up to the button */
|
||||
.tooltip-action-btn[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 35px; /* Match the before element */
|
||||
left: 50%; /* Center horizontally */
|
||||
transform: translateX(-50%); /* Center horizontally */
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
/* Arrow pointing down from button to tooltip */
|
||||
border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
|
||||
margin-left: 0; /* Reset margin */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 2500; /* High z-index */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Show tooltips on hover - but only on devices with hover capability (desktops) */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tooltip-action-btn[data-tooltip]:hover:before,
|
||||
.tooltip-action-btn[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.tooltip-action-btn[data-tooltip]:before,
|
||||
.tooltip-action-btn[data-tooltip]:after {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
content: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.delete:hover {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play:hover {
|
||||
background-color: #d1fae5;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.pause {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.pause:hover {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play-from-start {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play-from-start:hover {
|
||||
background-color: #e0e7ff;
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Adjust the new segment button style */
|
||||
.tooltip-action-btn.new-segment {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment:hover {
|
||||
background-color: #d1fae5;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment .tooltip-btn-text {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Disabled state for tooltip action buttons */
|
||||
.tooltip-action-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.disabled:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.disabled svg {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.disabled .tooltip-btn-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Ensure pause button is properly styled when disabled */
|
||||
.tooltip-action-btn.pause.disabled {
|
||||
color: #9ca3af !important;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.pause.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Ensure play button is properly styled when disabled */
|
||||
.tooltip-action-btn.play.disabled {
|
||||
color: #9ca3af !important;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.play.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Ensure time adjustment buttons are properly styled when disabled */
|
||||
.tooltip-time-btn.disabled {
|
||||
opacity: 0.5 !important;
|
||||
cursor: not-allowed !important;
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.tooltip-time-btn.disabled:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* Additional mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.two-row-tooltip {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tooltip-row:first-child {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-time-btn {
|
||||
min-width: 20px !important;
|
||||
font-size: 0.7rem !important;
|
||||
padding: 3px 6px !important;
|
||||
}
|
||||
|
||||
.tooltip-time-display {
|
||||
font-size: 0.8rem !important;
|
||||
padding: 3px 4px !important;
|
||||
min-width: 90px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tooltip-action-btn.new-segment {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
|
||||
.tooltip-action-btn[data-tooltip]:before {
|
||||
min-width: 100px;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
height: 24px;
|
||||
top: 33px; /* Maintain the same relative distance on mobile */
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]:after {
|
||||
top: 33px; /* Match the tooltip position */
|
||||
}
|
||||
}
|
||||
342
frontend-tools/chapters-editor/client/src/styles/VideoPlayer.css
Normal file
@ -0,0 +1,342 @@
|
||||
#chapters-editor-root {
|
||||
/* Tooltip styles - only on desktop where hover is available */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-tooltip]:before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 5px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]:hover:before,
|
||||
[data-tooltip]:hover:after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]:before,
|
||||
[data-tooltip]:after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
.video-player-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
aspect-ratio: 16/9;
|
||||
/* Prevent iOS Safari from showing default video controls */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
/* Prevent iOS Safari from showing default video controls */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
/* Additional iOS optimizations */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
.play-pause-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-pause-indicator::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.play-pause-indicator.play-icon::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 15px solid transparent;
|
||||
border-bottom: 15px solid transparent;
|
||||
border-left: 25px solid white;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.play-pause-indicator.pause-icon::before {
|
||||
width: 20px;
|
||||
height: 25px;
|
||||
border-left: 6px solid white;
|
||||
border-right: 6px solid white;
|
||||
}
|
||||
|
||||
/* iOS First-play indicator */
|
||||
.ios-first-play-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ios-play-message {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.video-current-time {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.video-time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin: 0 10px;
|
||||
touch-action: none; /* Prevent browser handling of drag gestures */
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.video-progress.dragging {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.video-progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: #ff0000;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-scrubber {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
width 0.1s ease,
|
||||
height 0.1s ease;
|
||||
}
|
||||
|
||||
/* Make the scrubber larger when dragging for better control */
|
||||
.video-progress.dragging .video-scrubber {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Enhance for touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.video-scrubber {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.video-progress.dragging .video-scrubber {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Create a larger invisible touch target */
|
||||
.video-scrubber:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mute-button,
|
||||
.fullscreen-button {
|
||||
min-width: auto;
|
||||
color: white;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Time tooltip that appears when dragging */
|
||||
.video-time-tooltip {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add a small arrow to the tooltip */
|
||||
.video-time-tooltip:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
20
frontend-tools/chapters-editor/components.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "client/src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
BIN
frontend-tools/chapters-editor/generated-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
47
frontend-tools/chapters-editor/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "video-trim-js",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"check": "tsc",
|
||||
"build:django": "vite build --config vite.chapters-editor.config.ts --outDir ../../../static/chapters_editor",
|
||||
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"clsx": "^2.1.1",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.3",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.17.30",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"esbuild": "^0.25.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend-tools/chapters-editor/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
9
frontend-tools/chapters-editor/shared/schema.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const insertUserSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||
export type User = InsertUser & { id: number };
|
||||
90
frontend-tools/chapters-editor/tailwind.config.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: {
|
||||
height: "0",
|
||||
},
|
||||
to: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
},
|
||||
"accordion-up": {
|
||||
from: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
to: {
|
||||
height: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
} satisfies Config;
|
||||
22
frontend-tools/chapters-editor/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["client/src/**/*"],
|
||||
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"lib": ["esnext", "dom", "dom.iterable"],
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./client/src/*"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'client', 'src'),
|
||||
},
|
||||
},
|
||||
root: path.resolve(__dirname, 'client'),
|
||||
define: {
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'client/src/main.tsx'),
|
||||
name: 'ChaptersEditor',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'chapters-editor.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Ensure CSS file has a predictable name
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
|
||||
return assetInfo.name;
|
||||
},
|
||||
// Add this to ensure the final bundle exposes React correctly
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Output to Django's static directory
|
||||
outDir: '../../../static/video_editor',
|
||||
emptyOutDir: true,
|
||||
external: ['react', 'react-dom'],
|
||||
},
|
||||
});
|
||||
22
frontend-tools/chapters-editor/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// Get current directory
|
||||
const __dirname = path.resolve();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "client", "src"),
|
||||
},
|
||||
},
|
||||
root: path.resolve(__dirname, "client"),
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, "dist/public"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||