merge main

This commit is contained in:
Markos Gogoulos 2025-10-20 15:44:25 +03:00
commit be69d7ce9a
362 changed files with 62419 additions and 239026 deletions

5
.gitignore vendored
View File

@ -30,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4
.DS_Store
static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4
frontend-tools/video-js/public/videos/sample-video-white.mp4
frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3

View File

@ -1 +1,3 @@
*
/templates/cms/*
/templates/*.html
*.scss

21
.prettierrc Normal file
View File

@ -0,0 +1,21 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"overrides": [
{
"files": ["*.css", "*.scss"],
"options": {
"singleQuote": false
}
}
]
}

View File

@ -1 +1 @@
VERSION = "6.8.210"
VERSION = "7.1.0"

View File

@ -30,7 +30,8 @@ fi
# We should do this only for folders that have a different owner, since it is an expensive operation
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
find /home/mediacms.io/mediacms ! \( -path "*.git*" \) -exec chown www-data:$TARGET_GID {} +
# Exclude package-lock.json files that may not exist or be removed during frontend setup
find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json" \) -exec chown www-data:$TARGET_GID {} + 2>/dev/null || true
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh

View File

@ -1,5 +1,6 @@
#!/bin/bash
# This script builds the video editor package and deploys the frontend assets to the static directory.
# How to run: sh deploy/scripts/build_and_deploy.sh
# Exit on any error
set -e
@ -12,9 +13,21 @@ cd frontend-tools/video-editor
yarn build:django
cd ../../
# Build chapter editor package
echo "Building chapters editor package..."
cd frontend-tools/chapters-editor
yarn build:django
cd ../../
# Build video js package
echo "Building video js package..."
cd frontend-tools/video-js
yarn build:django
cd ../../
# Run npm build in the frontend container
echo "Building frontend assets..."
docker compose -f docker-compose-dev.yaml exec frontend npm run dist
docker compose -f docker-compose/docker-compose-dev-updated.yaml exec frontend npm run dist
# Copy static assets to the static directory
echo "Copying static assets..."
@ -22,6 +35,6 @@ cp -r frontend/dist/static/* static/
# Restart the web service
echo "Restarting web service..."
docker compose -f docker-compose-dev.yaml restart web
docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
echo "Build and deployment completed successfully!"

View File

@ -33,55 +33,35 @@ services:
volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- npm_global:/home/node/.npm-global
- npm_cache:/home/node/.npm
working_dir: /home/mediacms.io/mediacms/frontend/
command: >
bash -c "
echo 'Setting up npm global directory...' &&
mkdir -p /home/node/.npm-global &&
chown -R node:node /home/node/.npm-global &&
echo 'Setting up permissions...' &&
chown -R node:node /home/mediacms.io/mediacms/frontend &&
echo 'Cleaning up node_modules...' &&
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
echo 'Switching to node user...' &&
su node -c '
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
echo \"Setting up frontend...\" &&
rm -f package-lock.json &&
rm -f packages/player/package-lock.json &&
rm -f packages/scripts/package-lock.json &&
echo \"Installing dependencies...\" &&
npm install --legacy-peer-deps &&
echo \"Setting up workspaces...\" &&
npm install -g npm@latest &&
echo 'Checking dependencies...' &&
if [ ! -f node_modules/.install-complete ]; then
echo 'First-time setup or dependencies changed, installing...' &&
npm install --legacy-peer-deps --cache /home/node/.npm &&
cd packages/scripts &&
npm install --legacy-peer-deps &&
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
npm install tslib@2.6.2 --save --legacy-peer-deps &&
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
npm install --legacy-peer-deps &&
npm install --legacy-peer-deps --cache /home/node/.npm &&
npm run build &&
cd ../.. &&
cd packages/player &&
npm install --legacy-peer-deps &&
touch node_modules/.install-complete &&
echo 'Dependencies installed successfully'
else
echo 'Dependencies already installed, skipping installation...' &&
if [ ! -d packages/scripts/dist ]; then
echo 'Building scripts package...' &&
cd packages/scripts &&
npm run build &&
cd ../.. &&
echo \"Starting development server...\" &&
cd ../..
fi
fi &&
echo 'Starting development server...' &&
npm run start
'"
"
env_file:
- ${PWD}/frontend/.env
environment:
- NPM_CONFIG_PREFIX=/home/node/.npm-global
ports:
- "8088:8088"
depends_on:
@ -140,6 +120,5 @@ services:
volumes:
frontend_node_modules:
player_node_modules:
scripts_node_modules:
npm_global:
npm_cache:

View File

@ -640,7 +640,7 @@ def handle_video_chapters(media, chapters):
else:
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return media.chapter_data
return {'chapters': media.chapter_data}
def change_media_owner(media_id, new_user):

View File

@ -630,7 +630,7 @@ class Media(models.Model):
@property
def trim_video_url(self):
if self.media_type not in ["video"]:
if self.media_type not in ["video", "audio"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
@ -642,7 +642,7 @@ class Media(models.Model):
@property
def trim_video_path(self):
if self.media_type not in ["video"]:
if self.media_type not in ["video", "audio"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()

View File

@ -12,40 +12,19 @@ class VideoChapterData(models.Model):
class Meta:
unique_together = ['media']
def save(self, *args, **kwargs):
from .. import tasks
is_new = self.pk is None
if is_new or (not is_new and self._check_data_changed()):
super().save(*args, **kwargs)
tasks.produce_video_chapters.delay(self.pk)
else:
super().save(*args, **kwargs)
def _check_data_changed(self):
if self.pk:
old_instance = VideoChapterData.objects.get(pk=self.pk)
return old_instance.data != self.data
return False
@property
def chapter_data(self):
# ensure response is consistent
data = []
if self.data and isinstance(self.data, list):
for item in self.data:
if item.get("start") and item.get("title"):
thumbnail = item.get("thumbnail")
if thumbnail:
thumbnail = helpers.url_from_path(thumbnail)
else:
thumbnail = "static/images/chapter_default.jpg"
data.append(
{
"start": item.get("start"),
"title": item.get("title"),
"thumbnail": thumbnail,
if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"):
chapter_item = {
'startTime': item.get("startTime"),
'endTime': item.get("endTime"),
'chapterTitle': item.get("chapterTitle"),
}
)
data.append(chapter_item)
return data

View File

@ -52,7 +52,6 @@ from .models import (
Subtitle,
Tag,
TranscriptionRequest,
VideoChapterData,
VideoTrimRequest,
)
@ -950,45 +949,6 @@ def update_encoding_size(encoding_id):
return False
@task(name="produce_video_chapters", queue="short_tasks")
def produce_video_chapters(chapter_id):
# this is not used
return False
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
if not chapter_object:
return False
media = chapter_object.media
video_path = media.media_file.path
output_folder = media.video_chapters_folder
chapters = chapter_object.data
width = 336
height = 188
if not os.path.exists(output_folder):
os.makedirs(output_folder)
results = []
for i, chapter in enumerate(chapters):
timestamp = chapter["start"]
title = chapter["title"]
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
output_path = os.path.join(output_folder, output_filename)
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
ret = run_command(command) # noqa
if os.path.exists(output_path) and get_file_type(output_path) == "image":
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
chapter_object.data = results
chapter_object.save(update_fields=["data"])
return True
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
def post_trim_action(friendly_token):
"""Perform post-processing actions after video trimming

View File

@ -244,8 +244,6 @@ def history(request):
@csrf_exempt
@login_required
def video_chapters(request, friendly_token):
# this is not ready...
return False
if not request.method == "POST":
return HttpResponseRedirect("/")
@ -258,20 +256,26 @@ def video_chapters(request, friendly_token):
return HttpResponseRedirect("/")
try:
data = json.loads(request.body)["chapters"]
request_data = json.loads(request.body)
data = request_data.get("chapters")
if data is None:
return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400)
chapters = []
for _, chapter_data in enumerate(data):
start_time = chapter_data.get('start')
title = chapter_data.get('title')
if start_time and title:
start_time = chapter_data.get('startTime')
end_time = chapter_data.get('endTime')
chapter_title = chapter_data.get('chapterTitle')
if start_time and end_time and chapter_title:
chapters.append(
{
'start': start_time,
'title': title,
'startTime': start_time,
'endTime': end_time,
'chapterTitle': chapter_title,
}
)
except Exception as e: # noqa
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400)
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400)
ret = handle_video_chapters(media, chapters)
@ -358,8 +362,6 @@ def publish_media(request):
@login_required
def edit_chapters(request):
"""Edit chapters"""
# not implemented yet
return False
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
@ -371,10 +373,11 @@ def edit_chapters(request):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
chapters = media.chapter_data
return render(
request,
"cms/edit_chapters.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token},
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
)
@ -426,7 +429,7 @@ def edit_video(request):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not media.media_type == "video":
if media.media_type not in ["video", "audio"]:
messages.add_message(request, messages.INFO, "Media is not video")
return HttpResponseRedirect(media.get_absolute_url())

View 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

View File

@ -0,0 +1,5 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"prettier.configPath": ".prettierrc"
}

View File

@ -0,0 +1,255 @@
# 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
## 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.

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -0,0 +1,184 @@
import { formatDetailedTime } from './lib/timeUtils';
import logger from './lib/logger';
import VideoPlayer from '@/components/VideoPlayer';
import TimelineControls from '@/components/TimelineControls';
import EditingTools from '@/components/EditingTools';
import ClipSegments from '@/components/ClipSegments';
import MobilePlayPrompt from '@/components/IOSPlayPrompt';
import useVideoChapters from '@/hooks/useVideoChapters';
import { useEffect } from 'react';
const App = () => {
const {
videoRef,
currentTime,
duration,
isPlaying,
setIsPlaying,
isMuted,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
selectedSegmentId,
hasUnsavedChanges,
historyPosition,
history,
handleTrimStartChange,
handleTrimEndChange,
handleZoomChange,
handleMobileSafeSeek,
handleSplit,
handleReset,
handleUndo,
handleRedo,
toggleMute,
handleSegmentUpdate,
handleChapterSave,
handleSelectedSegmentChange,
isMobile,
videoInitialized,
setVideoInitialized,
initializeSafariIfNeeded,
} = useVideoChapters();
const handlePlay = async () => {
if (!videoRef.current) return;
const video = videoRef.current;
// If already playing, just pause the video
if (isPlaying) {
video.pause();
setIsPlaying(false);
logger.debug('Video paused');
return;
}
// Safari: Try to initialize if needed before playing
if (duration === 0) {
const initialized = await initializeSafariIfNeeded();
if (initialized) {
// Wait a moment for initialization to complete
setTimeout(() => handlePlay(), 200);
return;
}
}
// Start playing - no boundary checking, play through entire timeline
video
.play()
.then(() => {
setIsPlaying(true);
setVideoInitialized(true);
logger.debug('Continuous playback started from:', formatDetailedTime(video.currentTime));
})
.catch((err) => {
console.error('Error playing video:', err);
});
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't handle keyboard shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
switch (event.code) {
case 'Space':
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
handlePlay();
break;
case 'ArrowLeft':
event.preventDefault();
if (videoRef.current) {
const newTime = Math.max(currentTime - 10, 0);
handleMobileSafeSeek(newTime);
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
}
break;
case 'ArrowRight':
event.preventDefault();
if (videoRef.current) {
const newTime = Math.min(currentTime + 10, duration);
handleMobileSafeSeek(newTime);
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
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}
onPlay={handlePlay}
isPlaying={isPlaying}
canUndo={historyPosition > 0}
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}
duration={duration}
thumbnails={[]}
trimStart={trimStart}
trimEnd={trimEnd}
splitPoints={splitPoints}
zoomLevel={zoomLevel}
clipSegments={clipSegments}
selectedSegmentId={selectedSegmentId}
onSelectedSegmentChange={handleSelectedSegmentChange}
onSegmentUpdate={handleSegmentUpdate}
onChapterSave={handleChapterSave}
onTrimStartChange={handleTrimStartChange}
onTrimEndChange={handleTrimEndChange}
onZoomChange={handleZoomChange}
onSeek={handleMobileSafeSeek}
videoRef={videoRef}
hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
onPlayPause={handlePlay}
/>
{/* Clip Segments */}
<ClipSegments segments={clipSegments} selectedSegmentId={selectedSegmentId} />
</div>
</div>
);
};
export default App;

View File

@ -0,0 +1,6 @@
// Import the audio poster image as a module
// Vite will handle this and provide the correct URL
import audioPosterJpg from '../../public/audio-poster.jpg';
export const AUDIO_POSTER_URL = audioPosterJpg;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,93 @@
import { formatTime, formatLongTime } from '@/lib/timeUtils';
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
chapterTitle: string;
startTime: number;
endTime: number;
}
interface ClipSegmentsProps {
segments: Segment[];
selectedSegmentId?: number | null;
}
const ClipSegments = ({ segments, selectedSegmentId }: 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}`;
};
// Get selected segment
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Chapters</h3>
{sortedSegments.map((segment, index) => (
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)} ${selectedSegmentId === segment.id ? 'selected' : ''}`}
>
<div className="segment-content">
<div className="segment-info">
<div className="segment-title">
{segment.chapterTitle ? (
<span className="chapter-title">{segment.chapterTitle}</span>
) : (
<span className="default-title">Chapter {index + 1}</span>
)}
</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<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 chapters created yet. Use the split button to create chapter segments.
</div>
)}
</div>
);
};
export default ClipSegments;

View File

@ -0,0 +1,219 @@
import '../styles/EditingTools.css';
import { useEffect, useState } from 'react';
import logger from '@/lib/logger';
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPlaying?: boolean;
}
const EditingTools = ({
onSplit,
onReset,
onUndo,
onRedo,
onPlay,
canUndo,
canRedo,
isPlaying = 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') {
logger.debug('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 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 */}
<button
className="button play-button"
onClick={handlePlay}
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
style={{ fontSize: '0.875rem' }}
>
{isPlaying ? (
<>
<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="Undo last action"
disabled={!canUndo}
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="Redo last undone action"
disabled={!canRedo}
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="Reset to full video"
>
<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;

View File

@ -0,0 +1,60 @@
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 or Safari browser
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
);
};
// Only show for mobile devices
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">
<button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing...
</button>
</div>
</div>
);
};
export default MobilePlayPrompt;

View File

@ -0,0 +1,197 @@
import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
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);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// 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(() => {
let url = '';
if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) {
url = source.src;
}
} else {
// Fallback to sample video if needed
url = '/videos/sample-video.mp4';
}
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [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"
poster={posterImage}
>
<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;

View File

@ -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" style={{ minWidth: '24px', minHeight: '24px' }}>
<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;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,492 @@
import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
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';
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device and Safari browser
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
const checkSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
setIsIOS(checkIOS());
// Store Safari detection globally for other components
if (typeof window !== 'undefined') {
(window as any).isSafari = checkSafari();
}
// 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="metadata"
crossOrigin="anonymous"
onClick={handleVideoClick}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
poster={posterImage}
>
<source src={sampleVideoUrl} type="video/mp4" />
{/* Safari fallback for audio files */}
<source src={sampleVideoUrl} type="audio/mp4" />
<source src={sampleVideoUrl} type="audio/mpeg" />
<p>Your browser doesn't support HTML5 video or audio.</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;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,796 @@
@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;
}
.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;
}
}

View 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;

View 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,
},
},
});

View 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);
};

View 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));
}

View File

@ -0,0 +1,17 @@
/**
* 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}%)`;
};

View File

@ -0,0 +1,39 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
if (typeof window !== 'undefined') {
window.MEDIA_DATA = {
videoUrl: '',
mediaId: '',
posterUrl: ''
};
window.lastSeekedPosition = 0;
}
declare global {
interface Window {
MEDIA_DATA: {
videoUrl: string;
mediaId: string;
posterUrl?: 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();
}

View File

@ -0,0 +1,86 @@
// API service for video trimming operations
import logger from '../lib/logger';
// Helper function to simulate delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Auto-save interface
interface AutoSaveRequest {
chapters: {
startTime: string;
endTime: string;
chapterTitle?: string;
}[];
}
interface AutoSaveResponse {
success: boolean;
status?: string;
timestamp: string;
chapters?: {
startTime: string;
endTime: string;
chapterTitle: string;
}[];
updated_at?: string;
error?: string;
}
// Auto-save API function
export const autoSaveVideo = async (mediaId: string, data: AutoSaveRequest): Promise<AutoSaveResponse> => {
try {
const response = await fetch(`/api/v1/media/${mediaId}/chapters`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
logger.debug('response', response);
if (!response.ok) {
// For error responses, return with error status
if (response.status === 404) {
// If endpoint not ready (404), return mock success response
const timestamp = new Date().toISOString();
return {
success: true,
timestamp: timestamp,
};
} else {
// Handle other error responses
try {
const errorData = await response.json();
return {
success: false,
timestamp: new Date().toISOString(),
error: errorData.error || 'Auto-save failed (videoApi.ts)',
};
} catch (parseError) {
return {
success: false,
timestamp: new Date().toISOString(),
error: 'Auto-save failed (videoApi.ts)',
};
}
}
}
// Successful response
const jsonResponse = await response.json();
// Check if the response has the expected format
return {
success: true,
timestamp: jsonResponse.updated_at || new Date().toISOString(),
...jsonResponse,
};
} catch (error) {
// For any fetch errors, return mock success response
const timestamp = new Date().toISOString();
return {
success: true,
timestamp: timestamp,
};
}
};

View File

@ -0,0 +1,338 @@
#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-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin: 0;
}
.save-chapters-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
&.has-changes {
background-color: #10b981;
animation: pulse-green 2s infinite;
}
&.has-changes:hover {
background-color: #059669;
}
svg {
width: 1rem;
height: 1rem;
}
}
@keyframes pulse-green {
0%,
100% {
background-color: #10b981;
}
50% {
background-color: #34d399;
}
}
.chapter-editor {
background-color: #f8fafc;
border: 2px solid #3b82f6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s ease;
}
.chapter-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.chapter-editor-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.chapter-editor-segment {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.chapter-title-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
resize: vertical;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.chapter-editor-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
.segment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background-color: rgba(59, 130, 246, 0.05);
}
}
.segment-content {
display: flex;
align-items: center;
}
.segment-info {
display: flex;
flex-direction: column;
}
.segment-title {
font-weight: 500;
font-size: 0.875rem;
color: black;
}
.chapter-title {
color: #1f2937;
font-weight: 600;
}
.default-title {
color: #6b7280;
font-style: italic;
}
.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);
}
/* Responsive styles */
@media (max-width: 768px) {
.clip-segments-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.save-chapters-button {
justify-content: center;
}
.chapter-editor-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.chapter-editor-segment {
align-self: stretch;
text-align: center;
}
}
}

View File

@ -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%;
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,341 @@
.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 */
margin-top: 5px;
}
.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 */
}
}

View 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);
}
}

View File

@ -0,0 +1,32 @@
/// <reference types="vite/client" />
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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 };

View 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;

View 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/*"],
}
}
}

View File

@ -0,0 +1,56 @@
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: {
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
// Keep original names for image assets
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
return assetInfo.name;
}
return assetInfo.name || 'asset-[hash][extname]';
},
// Inline small assets, emit larger ones
inlineDynamicImports: true,
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
// Output to Django's static directory
outDir: '../../../static/video_editor',
emptyOutDir: true,
external: ['react', 'react-dom'],
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
},
});

View 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,
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,22 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"trailingComma": "none",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"overrides": [
{
"files": ["*.css", "*.scss"],
"options": {
"singleQuote": false
}
}
]
}

View File

@ -16,9 +16,6 @@ A modern browser-based video editing tool built with React and TypeScript that i
- React 18
- TypeScript
- Vite
- Tailwind CSS
- Express (for development server)
- Drizzle ORM
## Installation

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

@ -236,6 +236,46 @@ const App = () => {
});
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Don't handle keyboard shortcuts if user is typing in an input field
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
switch (event.code) {
case 'Space':
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
handlePlay();
break;
case 'ArrowLeft':
event.preventDefault();
if (videoRef.current) {
const newTime = Math.max(currentTime - 10, 0);
handleMobileSafeSeek(newTime);
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
}
break;
case 'ArrowRight':
event.preventDefault();
if (videoRef.current) {
const newTime = Math.min(currentTime + 10, duration);
handleMobileSafeSeek(newTime);
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
return (
<div className="bg-background min-h-screen">
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />

View File

@ -0,0 +1,6 @@
// Import the audio poster image as a module
// Vite will handle this and provide the correct URL
import audioPosterJpg from '../../public/audio-poster.jpg';
export const AUDIO_POSTER_URL = audioPosterJpg;

View File

@ -1,5 +1,5 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils";
import "../styles/ClipSegments.css";
import { formatTime, formatLongTime } from '@/lib/timeUtils';
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
@ -20,8 +20,8 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Handle delete segment click
const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event
const deleteEvent = new CustomEvent("delete-segment", {
detail: { segmentId }
const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId },
});
document.dispatchEvent(deleteEvent);
};
@ -74,9 +74,7 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.
</div>
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
)}
</div>
);

View File

@ -1,5 +1,6 @@
import "../styles/EditingTools.css";
import { useEffect, useState } from "react";
import '../styles/EditingTools.css';
import { useEffect, useState } from 'react';
import logger from '@/lib/logger';
interface EditingToolsProps {
onSplit: () => void;
@ -24,7 +25,7 @@ const EditingTools = ({
canUndo,
canRedo,
isPlaying = false,
isPlayingSegments = false
isPlayingSegments = false,
}: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false);
@ -34,15 +35,15 @@ const EditingTools = ({
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", 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);
if (typeof window !== 'undefined') {
logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
}
// Call the original handler
@ -59,9 +60,9 @@ const EditingTools = ({
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
isPlayingSegments ? 'Stop segments playback' : 'Play segments in one continuous flow'
}
style={{ fontSize: "0.875rem" }}
style={{ fontSize: '0.875rem' }}
>
{isPlayingSegments ? (
<>
@ -133,10 +134,10 @@ const EditingTools = ({
{/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && (
<button
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
onClick={handlePlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: "0.875rem" }}
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
style={{ fontSize: '0.875rem' }}
disabled={isPlayingSegments}
>
{isPlaying && !isPlayingSegments ? (
@ -208,7 +209,7 @@ const EditingTools = ({
<button
className="button"
aria-label="Undo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'}
disabled={!canUndo || isPlayingSegments}
onClick={onUndo}
>
@ -229,7 +230,7 @@ const EditingTools = ({
<button
className="button"
aria-label="Redo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Redo last undone action'}
disabled={!canRedo || isPlayingSegments}
onClick={onRedo}
>
@ -251,7 +252,7 @@ const EditingTools = ({
<button
className="button"
onClick={onReset}
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
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">

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import "../styles/IOSPlayPrompt.css";
import React, { useState, useEffect } from 'react';
import '../styles/IOSPlayPrompt.css';
interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>;
@ -33,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
setIsVisible(false);
};
video.addEventListener("play", handlePlay);
video.addEventListener('play', handlePlay);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener('play', handlePlay);
};
}, [videoRef]);

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from "react";
import { formatTime } from "@/lib/timeUtils";
import "../styles/IOSVideoPlayer.css";
import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
@ -9,8 +10,9 @@ interface IOSVideoPlayerProps {
}
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@ -26,15 +28,25 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
// 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;
let url = '';
if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) {
setVideoUrl(source.src);
url = source.src;
}
} else {
// Fallback to sample video if needed
setVideoUrl("/videos/sample-video-10m.mp4");
url = '/videos/sample-video.mp3';
}
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]);
// Function to jump 15 seconds backward
@ -127,6 +139,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>

View File

@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import "../styles/Modal.css";
import React, { useEffect } from 'react';
import '../styles/Modal.css';
interface ModalProps {
isOpen: boolean;
@ -13,21 +13,21 @@ 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) {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscapeKey);
document.addEventListener('keydown', handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = "hidden";
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener("keydown", handleEscapeKey);
document.body.style.overflow = "";
document.removeEventListener('keydown', handleEscapeKey);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);

View File

@ -1,7 +1,8 @@
import React, { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
import logger from "../lib/logger";
import "../styles/VideoPlayer.css";
import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import logger from '../lib/logger';
import '../styles/VideoPlayer.css';
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
@ -22,7 +23,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
isMuted = false,
onPlayPause,
onSeek,
onToggleMute
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false);
@ -30,12 +31,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [lastPosition, setLastPosition] = useState<number | null>(null);
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
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-10m.mp4";
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp3';
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device
useEffect(() => {
@ -47,8 +57,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
setIsIOS(checkIOS());
// Check if video was previously initialized
if (typeof window !== "undefined") {
const wasInitialized = localStorage.getItem("video_initialized") === "true";
if (typeof window !== 'undefined') {
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
setHasInitialized(wasInitialized);
}
}, []);
@ -57,8 +67,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(true);
if (typeof window !== "undefined") {
localStorage.setItem("video_initialized", "true");
if (typeof window !== 'undefined') {
localStorage.setItem('video_initialized', 'true');
}
}
}, [isPlaying, hasInitialized]);
@ -70,15 +80,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// 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");
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") {
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
}
}
@ -86,25 +96,25 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug("Video play event fired");
logger.debug('Video play event fired');
if (isIOS) {
setHasInitialized(true);
localStorage.setItem("video_initialized", "true");
localStorage.setItem('video_initialized', 'true');
}
};
const handlePause = () => {
logger.debug("Video pause event fired");
logger.debug('Video pause event fired');
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
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);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
@ -150,12 +160,12 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleMouseUp = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Handle progress dragging for both mouse and touch events
@ -167,14 +177,16 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const seekTime = duration * clickPosition;
// Update tooltip position and time
setTooltipPosition({ x: e.clientX });
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") {
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
@ -202,14 +214,16 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleTouchEnd = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener("touchcancel", handleTouchEnd);
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);
document.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
// Handle touch dragging on progress bar
@ -217,7 +231,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (!progressRef.current) return;
// Get the touch coordinates
const touch = "touches" in e ? e.touches[0] : null;
const touch = 'touches' in e ? e.touches[0] : null;
if (!touch) return;
e.preventDefault(); // Prevent scrolling while dragging
@ -227,14 +241,16 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const seekTime = duration * touchPosition;
// Update tooltip position and time
setTooltipPosition({ x: touch.clientX });
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") {
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
@ -255,7 +271,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
@ -283,7 +299,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
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);
logger.debug('iOS: Explicitly setting position before play:', lastPosition);
// First, seek to the position
video.currentTime = lastPosition;
@ -296,13 +312,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
.play()
.then(() => {
logger.debug(
"iOS: Play started successfully at position:",
'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);
console.error('iOS: Error playing video:', err);
});
}
}, 50);
@ -311,11 +327,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
video
.play()
.then(() => {
logger.debug("Normal: Play started successfully");
logger.debug('Normal: Play started successfully');
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error("Error playing video:", err);
console.error('Error playing video:', err);
});
}
} else {
@ -337,6 +353,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
poster={posterImage}
>
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
@ -350,7 +367,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
@ -363,13 +380,23 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
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>
<div
className="video-progress-fill"
style={{
width: `${progressPercentage}%`,
}}
></div>
<div
className="video-scrubber"
style={{
left: `${progressPercentage}%`,
}}
></div>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
@ -377,7 +404,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: "translateX(-50%)"
transform: 'translateX(-50%)',
}}
>
{formatDetailedTime(tooltipTime)}
@ -391,9 +418,9 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"}
aria-label={isMuted ? 'Unmute' : 'Mute'}
onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"}
data-tooltip={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<svg

View File

@ -1,8 +1,8 @@
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";
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 {
@ -46,48 +46,23 @@ const useVideoTrimmer = () => {
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}`
);
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})`
(state, idx) => `${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})`
);
console.debug("History actions:", actions);
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") {
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;
@ -105,10 +80,10 @@ const useVideoTrimmer = () => {
// Create an initial segment that spans the entire video
const initialSegment: Segment = {
id: 1,
name: "segment",
name: 'segment',
startTime: 0,
endTime: video.duration,
thumbnail: segmentThumbnail
thumbnail: segmentThumbnail,
};
// Initialize history state with the full-length segment
@ -116,7 +91,7 @@ const useVideoTrimmer = () => {
trimStart: 0,
trimEnd: video.duration,
splitPoints: [],
clipSegments: [initialSegment]
clipSegments: [initialSegment],
};
setHistory([initialState]);
@ -159,19 +134,19 @@ const useVideoTrimmer = () => {
};
// Add event listeners
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("ended", handleEnded);
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);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, []);
@ -184,7 +159,7 @@ const useVideoTrimmer = () => {
video.pause();
} else {
// iOS Safari fix: Use the last seeked position if available
if (!isPlaying && typeof window !== "undefined" && window.lastSeekedPosition > 0) {
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) {
@ -201,12 +176,12 @@ const useVideoTrimmer = () => {
.then(() => {
// Play started successfully
// Reset the last seeked position after successfully starting playback
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
window.lastSeekedPosition = 0;
}
})
.catch((err) => {
console.error("Error starting playback:", err);
console.error('Error starting playback:', err);
setIsPlaying(false); // Reset state if play failed
});
}
@ -226,7 +201,7 @@ const useVideoTrimmer = () => {
// 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") {
if (typeof window !== 'undefined') {
window.lastSeekedPosition = time;
}
@ -239,7 +214,7 @@ const useVideoTrimmer = () => {
setIsPlaying(true); // Update state to reflect we're playing
})
.catch((err) => {
console.error("Error resuming playback:", err);
console.error('Error resuming playback:', err);
setIsPlaying(false);
});
}
@ -254,7 +229,7 @@ const useVideoTrimmer = () => {
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
action: action || 'manual_save', // Track the action that triggered this save
};
// Check if state is significantly different from last saved state
@ -339,16 +314,16 @@ const useVideoTrimmer = () => {
if (recordHistory) {
// Use a small timeout to ensure the state is updated
setTimeout(() => {
saveState(action || (isStart ? "adjust_trim_start" : "adjust_trim_end"));
saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end'));
}, 10);
}
}
};
document.addEventListener("update-trim", handleTrimUpdate as EventListener);
document.addEventListener('update-trim', handleTrimUpdate as EventListener);
return () => {
document.removeEventListener("update-trim", handleTrimUpdate as EventListener);
document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
};
}, []);
@ -360,11 +335,11 @@ const useVideoTrimmer = () => {
// 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";
const actionType = e.detail.action || 'update_segments';
// Log the update details
logger.debug(
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
);
// Update segment state immediately for UI feedback
@ -384,7 +359,7 @@ const useVideoTrimmer = () => {
trimEnd,
splitPoints: [...splitPoints],
clipSegments: segmentsClone,
action: actionType // Store the action type in the state
action: actionType, // Store the action type in the state
};
// Get the current history position to ensure we're using the latest value
@ -405,16 +380,12 @@ const useVideoTrimmer = () => {
// 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}`
);
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)`
);
logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`);
}
}
};
@ -423,8 +394,8 @@ const useVideoTrimmer = () => {
const customEvent = e as CustomEvent;
if (
customEvent.detail &&
typeof customEvent.detail.time === "number" &&
typeof customEvent.detail.segmentId === "number"
typeof customEvent.detail.time === 'number' &&
typeof customEvent.detail.segmentId === 'number'
) {
// Get the time and segment ID from the event
const timeToSplit = customEvent.detail.time;
@ -457,7 +428,7 @@ const useVideoTrimmer = () => {
name: `${segmentToSplit.name}-A`,
startTime: segmentToSplit.startTime,
endTime: timeToSplit,
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead
};
// Create second half of the split segment - no thumbnail needed
@ -466,7 +437,7 @@ const useVideoTrimmer = () => {
name: `${segmentToSplit.name}-B`,
startTime: timeToSplit,
endTime: segmentToSplit.endTime,
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segments
@ -477,14 +448,14 @@ const useVideoTrimmer = () => {
// Update state
setClipSegments(newSegments);
saveState("split_segment");
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") {
if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') {
const segmentId = customEvent.detail.segmentId;
// Find and remove the segment
@ -497,10 +468,10 @@ const useVideoTrimmer = () => {
// No need to generate a thumbnail - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
name: 'segment',
startTime: 0,
endTime: videoRef.current.duration,
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead
};
// Reset the trim points as well
@ -512,32 +483,32 @@ const useVideoTrimmer = () => {
// Just update the segments normally
setClipSegments(newSegments);
}
saveState("delete_segment");
saveState('delete_segment');
}
}
};
document.addEventListener("update-segments", handleUpdateSegments as EventListener);
document.addEventListener("split-segment", handleSplitSegment as EventListener);
document.addEventListener("delete-segment", handleDeleteSegment as EventListener);
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);
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");
saveState('adjust_trim_start');
};
// Handle trim end change
const handleTrimEndChange = (time: number) => {
setTrimEnd(time);
saveState("adjust_trim_end");
saveState('adjust_trim_end');
};
// Handle split at current position
@ -563,7 +534,7 @@ const useVideoTrimmer = () => {
name: `Segment ${i + 1}`,
startTime,
endTime,
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead
});
startTime = endTime;
@ -571,7 +542,7 @@ const useVideoTrimmer = () => {
}
setClipSegments(newSegments);
saveState("create_split_points");
saveState('create_split_points');
}
};
@ -587,14 +558,14 @@ const useVideoTrimmer = () => {
// No need to generate thumbnails - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
name: 'segment',
startTime: 0,
endTime: duration,
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead
};
setClipSegments([defaultSegment]);
saveState("reset_all");
saveState('reset_all');
};
// Handle undo
@ -607,7 +578,7 @@ const useVideoTrimmer = () => {
// Log segment details to help debug
logger.debug(
"Segment details after undo:",
'Segment details after undo:',
previousState.clipSegments.map(
(seg) =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
@ -621,7 +592,7 @@ const useVideoTrimmer = () => {
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
setHistoryPosition(historyPosition - 1);
} else {
logger.debug("Cannot undo: at earliest history position");
logger.debug('Cannot undo: at earliest history position');
}
};
@ -635,7 +606,7 @@ const useVideoTrimmer = () => {
// Log segment details to help debug
logger.debug(
"Segment details after redo:",
'Segment details after redo:',
nextState.clipSegments.map(
(seg) =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
@ -649,7 +620,7 @@ const useVideoTrimmer = () => {
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
setHistoryPosition(historyPosition + 1);
} else {
logger.debug("Cannot redo: at latest history position");
logger.debug('Cannot redo: at latest history position');
}
};
@ -669,10 +640,10 @@ const useVideoTrimmer = () => {
setIsPlaying(false);
} else {
// iOS Safari fix: Check for lastSeekedPosition
if (typeof window !== "undefined" && window.lastSeekedPosition > 0) {
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);
logger.debug('handlePlay: Using lastSeekedPosition', window.lastSeekedPosition);
video.currentTime = window.lastSeekedPosition;
}
}
@ -683,12 +654,12 @@ const useVideoTrimmer = () => {
.then(() => {
setIsPlaying(true);
// Reset lastSeekedPosition after successful play
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
window.lastSeekedPosition = 0;
}
})
.catch((err) => {
console.error("Error playing video:", err);
console.error('Error playing video:', err);
setIsPlaying(false); // Reset state if play failed
});
}
@ -710,28 +681,28 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving
const saveData = {
type: "save",
type: 'save',
segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
endTime: formatDetailedTime(segment.endTime),
})),
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === "development") {
console.debug("Saving data:", saveData);
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");
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");
saveState('save');
// In a real implementation, this would make a POST request to save the data
// logger.debug("Save data:", saveData);
@ -744,28 +715,28 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving as a copy
const saveData = {
type: "save_as_a_copy",
type: 'save_as_a_copy',
segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
endTime: formatDetailedTime(segment.endTime),
})),
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === "development") {
console.debug("Saving data as copy:", saveData);
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");
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");
saveState('save_copy');
};
// Handle save segments individually action
@ -775,27 +746,27 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving individual segments
const saveData = {
type: "save_segments",
type: 'save_segments',
segments: sortedSegments.map((segment) => ({
name: segment.name,
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
endTime: formatDetailedTime(segment.endTime),
})),
};
// Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === "development") {
console.debug("Saving data as segments:", saveData);
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");
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");
saveState('save_segments');
};
// Handle seeking with mobile check
@ -808,10 +779,8 @@ const useVideoTrimmer = () => {
// 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
);
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);
@ -845,9 +814,9 @@ const useVideoTrimmer = () => {
// If video is somehow paused, ensure it keeps playing
if (video.paused) {
logger.debug("Ensuring playback continues to next segment");
logger.debug('Ensuring playback continues to next segment');
video.play().catch((err) => {
console.error("Error continuing segment playback:", err);
console.error('Error continuing segment playback:', err);
});
}
} else {
@ -855,12 +824,12 @@ const useVideoTrimmer = () => {
video.pause();
setIsPlayingSegments(false);
setCurrentSegmentIndex(0);
video.removeEventListener("timeupdate", handleSegmentsPlayback);
video.removeEventListener('timeupdate', handleSegmentsPlayback);
}
}
};
video.addEventListener("timeupdate", handleSegmentsPlayback);
video.addEventListener('timeupdate', handleSegmentsPlayback);
// Start playing if not already playing
if (video.paused && orderedSegments.length > 0) {
@ -869,7 +838,7 @@ const useVideoTrimmer = () => {
}
return () => {
video.removeEventListener("timeupdate", handleSegmentsPlayback);
video.removeEventListener('timeupdate', handleSegmentsPlayback);
};
}, [isPlayingSegments, currentSegmentIndex, clipSegments]);
@ -878,20 +847,15 @@ const useVideoTrimmer = () => {
const handleSegmentIndexUpdate = (event: CustomEvent) => {
const { segmentIndex } = event.detail;
if (isPlayingSegments && segmentIndex !== currentSegmentIndex) {
logger.debug(
`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`
);
logger.debug(`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`);
setCurrentSegmentIndex(segmentIndex);
}
};
document.addEventListener("update-segment-index", handleSegmentIndexUpdate as EventListener);
document.addEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
return () => {
document.removeEventListener(
"update-segment-index",
handleSegmentIndexUpdate as EventListener
);
document.removeEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener);
};
}, [isPlayingSegments, currentSegmentIndex]);
@ -920,11 +884,11 @@ const useVideoTrimmer = () => {
// Start playback with proper error handling
video.play().catch((err) => {
console.error("Error starting segments playback:", err);
console.error('Error starting segments playback:', err);
setIsPlayingSegments(false);
});
logger.debug("Starting playback of all segments continuously");
logger.debug('Starting playback of all segments continuously');
}
};
@ -960,7 +924,7 @@ const useVideoTrimmer = () => {
handleSaveSegments,
isMobile,
videoInitialized,
setVideoInitialized
setVideoInitialized,
};
};

View File

@ -7,7 +7,7 @@ const logger = {
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === 'development') {
console.debug(...args);
}
},
@ -25,7 +25,7 @@ const logger = {
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args)
info: (...args: any[]) => console.info(...args),
};
export default logger;

View File

@ -1,4 +1,4 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
import { QueryClient, QueryFunction } from '@tanstack/react-query';
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
@ -7,31 +7,27 @@ async function throwIfResNotOk(res: Response) {
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined
): Promise<Response> {
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" } : {},
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include"
credentials: 'include',
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
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"
credentials: 'include',
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
return null;
}
@ -42,14 +38,14 @@ export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryF
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
queryFn: getQueryFn({ on401: 'throw' }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false
retry: false,
},
mutations: {
retry: false
}
}
retry: false,
},
},
});

View File

@ -2,17 +2,17 @@
* 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";
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");
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}`;
};

View File

@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));

View File

@ -20,17 +20,14 @@ export const generateSolidColor = (time: number, duration: number): string => {
* 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> => {
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");
const canvas = document.createElement('canvas');
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
const ctx = canvas.getContext("2d");
const ctx = canvas.getContext('2d');
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
@ -41,7 +38,7 @@ export const generateThumbnail = async (
}
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL("image/png", 0.5);
const dataUrl = canvas.toDataURL('image/png', 0.5);
resolve(dataUrl);
});
};

View File

@ -5,7 +5,8 @@ import "./index.css";
if (typeof window !== "undefined") {
window.MEDIA_DATA = {
videoUrl: "",
mediaId: ""
mediaId: "",
posterUrl: ""
};
window.lastSeekedPosition = 0;
}
@ -15,6 +16,7 @@ declare global {
MEDIA_DATA: {
videoUrl: string;
mediaId: string;
posterUrl?: string;
};
seekToFunction?: (time: number) => void;
lastSeekedPosition: number;

View File

@ -1,5 +1,5 @@
// API service for video trimming operations
import logger from '../lib/logger';
interface TrimVideoRequest {
segments: {
startTime: string;
@ -20,18 +20,102 @@ interface TrimVideoResponse {
// Helper function to simulate delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later
export const trimVideo = async (
mediaId: string,
data: TrimVideoRequest
): Promise<TrimVideoResponse> => {
// Auto-save interface
interface AutoSaveRequest {
segments: {
startTime: string;
endTime: string;
name?: string;
}[];
}
interface AutoSaveResponse {
success: boolean;
timestamp: string;
error?: string;
status?: string;
media_id?: string;
segments?: {
startTime: string;
endTime: string;
name: string;
}[];
updated_at?: string;
}
// Auto-save API function
export const autoSaveVideo = async (mediaId: string, data: AutoSaveRequest): Promise<AutoSaveResponse> => {
try {
const response = await fetch(`/api/v1/media/${mediaId}/save_trim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
logger.debug('response', response);
if (!response.ok) {
// For error responses, return with error status
if (response.status === 404) {
// If endpoint not ready (404), return mock success response
const timestamp = new Date().toISOString();
return {
success: true,
timestamp: timestamp,
};
} else {
// Handle other error responses
try {
const errorData = await response.json();
return {
success: false,
timestamp: new Date().toISOString(),
error: errorData.error || 'Auto-save failed',
};
} catch (parseError) {
return {
success: false,
timestamp: new Date().toISOString(),
error: 'Auto-save failed',
};
}
}
}
// Successful response
const jsonResponse = await response.json();
// Check if the response has the expected format
if (jsonResponse.status === 'success') {
return {
success: true,
timestamp: jsonResponse.updated_at || new Date().toISOString(),
...jsonResponse,
};
} else {
return {
success: false,
timestamp: new Date().toISOString(),
error: jsonResponse.error || 'Auto-save failed',
};
}
} catch (error) {
// For any fetch errors, return mock success response
const timestamp = new Date().toISOString();
return {
success: true,
timestamp: timestamp,
};
}
};
export const trimVideo = async (mediaId: string, data: TrimVideoRequest): 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)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
@ -43,17 +127,17 @@ export const trimVideo = async (
const errorData = await response.json();
return {
status: 400,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
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: ""
error: 'An error occurred during video processing',
msg: 'Video Processing Error',
url_redirect: '',
};
}
} else if (response.status !== 404) {
@ -63,17 +147,17 @@ export const trimVideo = async (
const errorData = await response.json();
return {
status: response.status,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
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: ""
error: 'An error occurred during video processing',
msg: 'Video Processing Error',
url_redirect: '',
};
}
} else {
@ -81,8 +165,8 @@ export const trimVideo = async (
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}`
msg: 'Video Processed Successfully', // Updated per requirements
url_redirect: `./view?m=${mediaId}`,
};
}
}
@ -91,17 +175,17 @@ export const trimVideo = async (
const jsonResponse = await response.json();
return {
status: 200,
msg: "Video Processed Successfully", // Ensure the success message is correct
msg: 'Video Processed Successfully', // Ensure the success message is correct
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
...jsonResponse
...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}`
msg: 'Video Processed Successfully', // Consistent with requirements
url_redirect: `./view?m=${mediaId}`,
};
}

View File

@ -0,0 +1,32 @@
/// <reference types="vite/client" />
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}

View File

@ -32,11 +32,17 @@ export default defineConfig({
},
rollupOptions: {
output: {
// Ensure CSS file has a predictable name
// Ensure CSS file has a predictable name and keep image assets
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'video-editor.css';
// Keep original names for image assets
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
return assetInfo.name;
}
return assetInfo.name || 'asset-[hash][extname]';
},
// Inline small assets, emit larger ones
inlineDynamicImports: true,
// Add this to ensure the final bundle exposes React correctly
globals: {
'react': 'React',
@ -47,6 +53,8 @@ export default defineConfig({
// Output to Django's static directory
outDir: '../../../static/video_editor',
emptyOutDir: true,
external: ['react', 'react-dom']
external: ['react', 'react-dom'],
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
},
});

View File

@ -0,0 +1,4 @@
# Copy this file to .env and adjust values as needed
# Set to true to enable development mode
VITE_DEV_MODE=true

26
frontend-tools/video-js/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
yt.readme.md

View File

View File

@ -0,0 +1,337 @@
# Video.js + React + Vite Demo
A **comprehensive demonstration** of integrating **video.js** with **React** and **Vite**, showcasing **ALL available video.js parameters** and options.
## 🚀 Features
- ✅ **Complete Video.js Options Implementation** - Every available parameter documented and demonstrated
- ✅ Video.js integration with React hooks
- ✅ Responsive video player with breakpoints
- ✅ Modern Vite build setup
- ✅ Clean and modern UI
- ✅ Comprehensive event handling and console logging
- ✅ Sample video demonstration
- ✅ **150+ Video.js Parameters** organized by category
- ✅ **Multiple configuration examples** for different use cases
## 🛠️ Technologies Used
- **React 19** - UI library
- **Vite 4.5.0** - Build tool and dev server (Node 16 compatible)
- **Video.js 8.23.3** - HTML5 video player (latest version)
- **JavaScript** - Programming language (no TypeScript)
## 📦 Installation
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
## 🎯 Project Structure
```
src/
├── VideoPlayer.jsx # Video.js React component
├── App.jsx # Main app with ALL video.js options
├── VideoJsOptionsReference.js # Complete options documentation
├── App.css # Application styles
├── main.jsx # React entry point
└── index.css # Global styles
```
## 📋 Complete Video.js Options Categories
### 🎬 Standard HTML5 Video Element Options
- `autoplay` - Can be boolean, 'muted', 'play', or 'any'
- `controls` - Show/hide player controls
- `height` / `width` - Player dimensions
- `loop` - Restart video when it ends
- `muted` - Start with audio muted
- `poster` - Poster image URL
- `preload` - 'auto', 'metadata', or 'none'
- `sources` - Array of video sources
### ⚡ Video.js-Specific Options
- `aspectRatio` - Maintains aspect ratio ('16:9', '4:3')
- `audioOnlyMode` - Hide video-specific controls
- `audioPosterMode` - Show poster persistently for audio
- `breakpoints` - Responsive breakpoints configuration
- `disablePictureInPicture` - Control PiP functionality
- `enableDocumentPictureInPicture` - Chrome 116+ PiP
- `enableSmoothSeeking` - Smoother seeking experience
- `experimentalSvgIcons` - Use SVG icons instead of font
- `fluid` - Responsive to container size
- `fullscreen` - Fullscreen API options
- `inactivityTimeout` - User inactive timeout in ms
- `language` / `languages` - Localization
- `liveui` / `liveTracker` - Live streaming features
- `normalizeAutoplay` - Consistent autoplay behavior
- `noUITitleAttributes` - Better accessibility
- `playbackRates` - Speed control options
- `playsinline` - iOS Safari behavior
- `preferFullWindow` - iOS fullscreen alternative
- `responsive` - Enable responsive breakpoints
- `skipButtons` - Forward/backward skip controls
- `spatialNavigation` - TV/remote control support
- `techOrder` - Playback technology preference
- `userActions` - Click, double-click, hotkeys configuration
### 🎛️ Component Options
- `controlBar` - Complete control bar customization
- Time displays (current, duration, remaining)
- Progress control and seek bar
- Volume control (horizontal/vertical)
- Playback controls (play/pause)
- Skip buttons (forward/backward)
- Fullscreen and Picture-in-Picture
- Subtitles, captions, audio tracks
- Live streaming controls
- `children` - Player child components array
### 🔧 Tech Options
- `html5` - HTML5 technology specific options
- `nativeControlsForTouch` - Touch device controls
- `nativeAudioTracks` / `nativeVideoTracks` - Track handling
- `nativeTextTracks` / `preloadTextTracks` - Subtitle handling
### 🚀 Advanced Options
- `plugins` - Plugin initialization
- `vtt.js` - Subtitle library URL
- `id` - Player element ID
- `posterImage` - Poster component control
## 🎮 Usage Examples
### Basic Usage
```jsx
import VideoPlayer from './VideoPlayer';
<VideoPlayer
options={{
controls: true,
fluid: true,
sources: [{ src: 'video.mp4', type: 'video/mp4' }],
}}
onReady={(player) => console.log('Ready!', player)}
/>;
```
### Advanced Configuration
```jsx
<VideoPlayer
options={{
// Responsive design
fluid: true,
responsive: true,
aspectRatio: '16:9',
// Playback features
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2],
enableSmoothSeeking: true,
// User interaction
userActions: {
hotkeys: true,
click: true,
doubleClick: true,
},
// Skip buttons
skipButtons: {
forward: 10,
backward: 10,
},
// Sources
sources: [
{ src: 'video.mp4', type: 'video/mp4' },
{ src: 'video.webm', type: 'video/webm' },
],
}}
/>
```
### Live Streaming Configuration
```jsx
<VideoPlayer
options={{
controls: true,
fluid: true,
liveui: true,
liveTracker: {
trackingThreshold: 30,
liveTolerance: 15,
},
controlBar: {
liveDisplay: true,
seekToLive: true,
},
sources: [{ src: 'stream.m3u8', type: 'application/x-mpegURL' }],
}}
/>
```
## ⌨️ Keyboard Shortcuts
| Key | Action |
| --------------------- | ------------------------------------ |
| **Spacebar** or **K** | Play/Pause |
| **M** | Mute/Unmute |
| **F** | Toggle Fullscreen |
| **←** **→** | Skip backward/forward (when enabled) |
| **↑** **↓** | Volume up/down |
## 🔧 Customization
### Responsive Breakpoints
```javascript
breakpoints: {
tiny: 210,
xsmall: 320,
small: 425,
medium: 768,
large: 1440,
xlarge: 2560,
huge: Infinity
}
```
### Control Bar Customization
```javascript
controlBar: {
// Enable/disable specific controls
playToggle: true,
volumePanel: true,
currentTimeDisplay: true,
durationDisplay: true,
progressControl: true,
fullscreenToggle: true,
// Skip buttons
skipButtons: {
forward: 10, // 10 second forward
backward: 10 // 10 second backward
},
// Volume control style
volumePanel: {
inline: false, // Vertical volume slider
}
}
```
### Event Handling
```javascript
const handlePlayerReady = (player) => {
// Set up comprehensive event listeners
player.on('play', () => console.log('Video started'));
player.on('pause', () => console.log('Video paused'));
player.on('volumechange', () => console.log('Volume:', player.volume()));
player.on('fullscreenchange', () => console.log('Fullscreen:', player.isFullscreen()));
player.on('ratechange', () => console.log('Speed:', player.playbackRate()));
player.on('seeking', () => console.log('Seeking to:', player.currentTime()));
};
```
## 📖 Option Categories Reference
### Playback Control
`autoplay`, `controls`, `loop`, `muted`, `preload`, `playbackRates`
### Layout & Responsive
`width`, `height`, `fluid`, `responsive`, `aspectRatio`, `breakpoints`
### Advanced Features
`skipButtons`, `userActions`, `hotkeys`, `enableSmoothSeeking`
### Accessibility
`language`, `noUITitleAttributes`, `spatialNavigation`
### Live Streaming
`liveui`, `liveTracker`, `techOrder`
### Mobile Optimization
`playsinline`, `nativeControlsForTouch`, `preferFullWindow`
### Component Customization
`controlBar`, `children`, `plugins`
## 📝 Configuration Files
- **`src/App.jsx`** - Complete implementation with all options
- **`src/VideoJsOptionsReference.js`** - Detailed documentation of every option
- **`src/VideoPlayer.jsx`** - React component wrapper
## 🚀 Development
```bash
# Start dev server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
```
## 🌟 What Makes This Implementation Special
1. **Complete Option Coverage** - Every single video.js option documented and implemented
2. **Organized by Category** - Options grouped logically for easy understanding
3. **Real-world Examples** - Multiple configuration examples for different use cases
4. **Comprehensive Events** - All player events logged with emojis for easy debugging
5. **Responsive Design** - Breakpoint system for different screen sizes
6. **Accessibility Ready** - Full keyboard navigation and screen reader support
7. **Modern React Integration** - Proper lifecycle management and cleanup
## 📊 Statistics
- **150+ Video.js Options** implemented and documented
- **8 Option Categories** with detailed explanations
- **5 Example Configurations** for different use cases
- **10+ Keyboard Shortcuts** supported
- **Responsive Breakpoints** for 7 different screen sizes
- **20+ Event Listeners** with detailed logging
## 📝 Notes
- The demo uses a sample video from Video.js CDN
- All player events are logged to the browser console with emojis
- The component properly handles cleanup on unmount
- Responsive design works on mobile and desktop
- Compatible with Node.js 16+ (Vite downgraded for compatibility)
- All options are documented with types, defaults, and descriptions
## 🔗 Useful Links
- [Video.js Official Documentation](https://videojs.com/)
- [Video.js Options Reference](https://videojs.com/guides/options/)
- [Video.js Plugins](https://videojs.com/plugins/)
- [React Integration Guide](https://videojs.com/guides/react/)
---
**Happy coding!** 🎉 This implementation serves as a complete reference for video.js integration with React!

View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0">
<div id="page-embed">
<div id="video-js-root-embed-old" class="video-js-root-embed-old"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" style="margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0; overflow: hidden; width: 100%; height: 100%">
<div id="page-embed" style="width: 100%; height: 100%; overflow: hidden">
<div id="video-js-root-embed" class="video-js-root-embed"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0">
<div id="video-js-root-main-old" class="video-js-root-main-old"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More