Compare commits

..

No commits in common. "main" and "v6.7.0" have entirely different histories.
main ... v6.7.0

366 changed files with 238694 additions and 56697 deletions

6
.gitignore vendored
View File

@ -5,7 +5,6 @@ media_files/original/
media_files/hls/ media_files/hls/
media_files/chunks/ media_files/chunks/
media_files/uploads/ media_files/uploads/
media_files/tinymce_media/
postgres_data/ postgres_data/
celerybeat-schedule celerybeat-schedule
logs/ logs/
@ -30,8 +29,3 @@ static/video_editor/videos/sample-video-37s.mp4
.DS_Store .DS_Store
static/video_editor/videos/sample-video-10m.mp4 static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.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,3 +1 @@
/templates/cms/* *
/templates/*.html
*.scss

View File

@ -1,21 +0,0 @@
{
"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

@ -105,23 +105,6 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
SITE_ID = 1 SITE_ID = 1
# these are the portal logos (dark and light)
# set new paths for svg or png if you want to override
# svg has priority over png, so if you want to use
# custom pngs and not svgs, remove the lines with svgs
# or set as empty strings
# example:
# PORTAL_LOGO_DARK_SVG = ""
# PORTAL_LOGO_LIGHT_SVG = ""
# place the files on static/images folder
PORTAL_LOGO_DARK_SVG = "/static/images/logo_dark.svg"
PORTAL_LOGO_DARK_PNG = "/static/images/logo_dark.png"
PORTAL_LOGO_LIGHT_SVG = "/static/images/logo_light.svg"
PORTAL_LOGO_LIGHT_PNG = "/static/images/logo_dark.png"
# paths to extra css files to be included, eg "/static/css/custom.css"
# place css inside static/css folder
EXTRA_CSS_PATHS = []
# protection agains anonymous users # protection agains anonymous users
# per ip address limit, for actions as like/dislike/report # per ip address limit, for actions as like/dislike/report
TIME_TO_ACTION_ANONYMOUS = 10 * 60 TIME_TO_ACTION_ANONYMOUS = 10 * 60

View File

@ -1 +1 @@
VERSION = "7.0.1-beta.8" VERSION = "6.7.0"

View File

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

View File

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

View File

@ -33,35 +33,55 @@ services:
volumes: volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/ - ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules - 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 - scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- npm_cache:/home/node/.npm - npm_global:/home/node/.npm-global
working_dir: /home/mediacms.io/mediacms/frontend/ working_dir: /home/mediacms.io/mediacms/frontend/
command: > command: >
bash -c " bash -c "
echo 'Checking dependencies...' && echo 'Setting up npm global directory...' &&
if [ ! -f node_modules/.install-complete ]; then mkdir -p /home/node/.npm-global &&
echo 'First-time setup or dependencies changed, installing...' && chown -R node:node /home/node/.npm-global &&
npm install --legacy-peer-deps --cache /home/node/.npm && 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 &&
cd packages/scripts && cd packages/scripts &&
npm install --legacy-peer-deps --cache /home/node/.npm && 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 run build && npm run build &&
cd ../.. && cd ../.. &&
touch node_modules/.install-complete && cd packages/player &&
echo 'Dependencies installed successfully' npm install --legacy-peer-deps &&
else
echo 'Dependencies already installed, skipping installation...' &&
if [ ! -d packages/scripts/dist ]; then
echo 'Building scripts package...' &&
cd packages/scripts &&
npm run build && npm run build &&
cd ../.. cd ../.. &&
fi echo \"Starting development server...\" &&
fi &&
echo 'Starting development server...' &&
npm run start npm run start
" '"
env_file: env_file:
- ${PWD}/frontend/.env - ${PWD}/frontend/.env
environment:
- NPM_CONFIG_PREFIX=/home/node/.npm-global
ports: ports:
- "8088:8088" - "8088:8088"
depends_on: depends_on:
@ -120,5 +140,6 @@ services:
volumes: volumes:
frontend_node_modules: frontend_node_modules:
player_node_modules:
scripts_node_modules: scripts_node_modules:
npm_cache: npm_global:

View File

@ -240,12 +240,7 @@ Docker Compose installation: edit `deploy/docker/local_settings.py`, make a chan
### 5.1 Change portal logo ### 5.1 Change portal logo
Find the default svg files for the white theme on `static/images/logo_dark.svg` and for the dark theme on `static/images/logo_light.svg` Set a new svg file for the white theme (`static/images/logo_dark.svg`) or the dark theme (`static/images/logo_light.svg`)
You can specify new svg paths to override by editing the `PORTAL_LOGO_DARK_SVG` and `PORTAL_LOGO_LIGHT_SVG` variables in `settings.py`.
You can also use custom pngs, by setting the variables `PORTAL_LOGO_DARK_PNG` and `PORTAL_LOGO_LIGHT_PNG` in `settings.py`. The svg files have priority over png files, so if both are set, svg files will be used.
In any case, make sure the files are placed on the static/images folder.
### 5.2 Set global portal title ### 5.2 Set global portal title

View File

@ -12,12 +12,6 @@ def stuff(request):
ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/') ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/')
ret["DEFAULT_THEME"] = settings.DEFAULT_THEME ret["DEFAULT_THEME"] = settings.DEFAULT_THEME
ret["PORTAL_NAME"] = settings.PORTAL_NAME ret["PORTAL_NAME"] = settings.PORTAL_NAME
ret["PORTAL_LOGO_DARK_SVG"] = getattr(settings, 'PORTAL_LOGO_DARK_SVG', "")
ret["PORTAL_LOGO_DARK_PNG"] = getattr(settings, 'PORTAL_LOGO_DARK_PNG', "")
ret["PORTAL_LOGO_LIGHT_SVG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_SVG', "")
ret["PORTAL_LOGO_LIGHT_PNG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_PNG', "")
ret["EXTRA_CSS_PATHS"] = getattr(settings, 'EXTRA_CSS_PATHS', [])
ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION
ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED

View File

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

View File

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

View File

@ -12,19 +12,40 @@ class VideoChapterData(models.Model):
class Meta: class Meta:
unique_together = ['media'] 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 @property
def chapter_data(self): def chapter_data(self):
# ensure response is consistent # ensure response is consistent
data = [] data = []
if self.data and isinstance(self.data, list):
for item in self.data: for item in self.data:
if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"): if item.get("start") and item.get("title"):
chapter_item = { thumbnail = item.get("thumbnail")
'startTime': item.get("startTime"), if thumbnail:
'endTime': item.get("endTime"), thumbnail = helpers.url_from_path(thumbnail)
'chapterTitle': item.get("chapterTitle"), else:
thumbnail = "static/images/chapter_default.jpg"
data.append(
{
"start": item.get("start"),
"title": item.get("title"),
"thumbnail": thumbnail,
} }
data.append(chapter_item) )
return data return data

View File

@ -52,6 +52,7 @@ from .models import (
Subtitle, Subtitle,
Tag, Tag,
TranscriptionRequest, TranscriptionRequest,
VideoChapterData,
VideoTrimRequest, VideoTrimRequest,
) )
@ -949,6 +950,45 @@ def update_encoding_size(encoding_id):
return False 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) @task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
def post_trim_action(friendly_token): def post_trim_action(friendly_token):
"""Perform post-processing actions after video trimming """Perform post-processing actions after video trimming

View File

@ -115,7 +115,7 @@ urlpatterns = [
if settings.USERS_NEEDS_TO_BE_APPROVED: if settings.USERS_NEEDS_TO_BE_APPROVED:
urlpatterns.append(re_path(r"^approval_required/", views.approval_required, name="approval_required")) urlpatterns.append(re_path(r"^approval_required", views.approval_required, name="approval_required"))
if hasattr(settings, "USE_SAML") and settings.USE_SAML: if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata")) urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))

View File

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

View File

@ -1,15 +0,0 @@
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

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

View File

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

@ -1,34 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 695 KiB

View File

@ -1,186 +0,0 @@
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) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
handleMobileSafeSeek(newTime);
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
}
break;
case 'ArrowRight':
event.preventDefault();
if (videoRef.current) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.min(videoRef.current.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, 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

@ -1,6 +0,0 @@
// 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 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 832 B

View File

@ -1 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 813 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 818 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 597 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 611 B

View File

@ -1,10 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 439 B

View File

@ -1,10 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 439 B

View File

@ -1 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 359 B

View File

@ -1,9 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 412 B

View File

@ -1,9 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 411 B

View File

@ -1 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 359 B

View File

@ -1,93 +0,0 @@
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

@ -1,219 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,197 +0,0 @@
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

@ -1,74 +0,0 @@
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;

View File

@ -1,492 +0,0 @@
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;

View File

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

@ -1,31 +0,0 @@
/**
* 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

@ -1,51 +0,0 @@
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

@ -1,34 +0,0 @@
/**
* 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

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

View File

@ -1,17 +0,0 @@
/**
* 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

@ -1,39 +0,0 @@
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

@ -1,86 +0,0 @@
// 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

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

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

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

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

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

@ -1,306 +0,0 @@
#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;
}
}

View File

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

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

@ -1,32 +0,0 @@
/// <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

@ -1,20 +0,0 @@
{
"$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.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,47 +0,0 @@
{
"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

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

View File

@ -1,9 +0,0 @@
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

@ -1,90 +0,0 @@
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

@ -1,22 +0,0 @@
{
"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

@ -1,56 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -0,0 +1,22 @@
{
"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,6 +16,9 @@ A modern browser-based video editing tool built with React and TypeScript that i
- React 18 - React 18
- TypeScript - TypeScript
- Vite - Vite
- Tailwind CSS
- Express (for development server)
- Drizzle ORM
## Installation ## Installation

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

View File

@ -236,48 +236,6 @@ 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) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
handleMobileSafeSeek(newTime);
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
}
break;
case 'ArrowRight':
event.preventDefault();
if (videoRef.current) {
// Use the video element's current time directly to avoid stale state
const newTime = Math.min(videoRef.current.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, duration, videoRef]);
return ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} /> <MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />

View File

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

View File

@ -1,6 +1,5 @@
import '../styles/EditingTools.css'; import "../styles/EditingTools.css";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import logger from '@/lib/logger';
interface EditingToolsProps { interface EditingToolsProps {
onSplit: () => void; onSplit: () => void;
@ -25,7 +24,7 @@ const EditingTools = ({
canUndo, canUndo,
canRedo, canRedo,
isPlaying = false, isPlaying = false,
isPlayingSegments = false, isPlayingSegments = false
}: EditingToolsProps) => { }: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false); const [isSmallScreen, setIsSmallScreen] = useState(false);
@ -35,15 +34,15 @@ const EditingTools = ({
}; };
checkScreenSize(); checkScreenSize();
window.addEventListener('resize', checkScreenSize); window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize); return () => window.removeEventListener("resize", checkScreenSize);
}, []); }, []);
// Handle play button click with iOS fix // Handle play button click with iOS fix
const handlePlay = () => { const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked // Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition); console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
} }
// Call the original handler // Call the original handler
@ -60,9 +59,9 @@ const EditingTools = ({
className={`button segments-button`} className={`button segments-button`}
onClick={onPlaySegments} onClick={onPlaySegments}
data-tooltip={ 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 ? ( {isPlayingSegments ? (
<> <>
@ -134,10 +133,10 @@ const EditingTools = ({
{/* Standard Play button (only shown when not in segments playback on small screens) */} {/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && ( {(!isPlayingSegments || !isSmallScreen) && (
<button <button
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`} className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
onClick={handlePlay} onClick={handlePlay}
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'} data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: '0.875rem' }} style={{ fontSize: "0.875rem" }}
disabled={isPlayingSegments} disabled={isPlayingSegments}
> >
{isPlaying && !isPlayingSegments ? ( {isPlaying && !isPlayingSegments ? (
@ -209,7 +208,7 @@ const EditingTools = ({
<button <button
className="button" className="button"
aria-label="Undo" aria-label="Undo"
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'} data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
disabled={!canUndo || isPlayingSegments} disabled={!canUndo || isPlayingSegments}
onClick={onUndo} onClick={onUndo}
> >
@ -230,7 +229,7 @@ const EditingTools = ({
<button <button
className="button" className="button"
aria-label="Redo" 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} disabled={!canRedo || isPlayingSegments}
onClick={onRedo} onClick={onRedo}
> >
@ -252,7 +251,7 @@ const EditingTools = ({
<button <button
className="button" className="button"
onClick={onReset} onClick={onReset}
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Reset to full video'} data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
disabled={isPlayingSegments} disabled={isPlayingSegments}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <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 React, { useState, useEffect } from "react";
import '../styles/IOSPlayPrompt.css'; import "../styles/IOSPlayPrompt.css";
interface MobilePlayPromptProps { interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
@ -33,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
setIsVisible(false); setIsVisible(false);
}; };
video.addEventListener('play', handlePlay); video.addEventListener("play", handlePlay);
return () => { return () => {
video.removeEventListener('play', handlePlay); video.removeEventListener("play", handlePlay);
}; };
}, [videoRef]); }, [videoRef]);

View File

@ -1,7 +1,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from "react";
import { formatTime } from '@/lib/timeUtils'; import { formatTime } from "@/lib/timeUtils";
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl'; import "../styles/IOSVideoPlayer.css";
import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps { interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
@ -10,9 +9,8 @@ interface IOSVideoPlayerProps {
} }
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: 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 [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@ -28,25 +26,15 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
// Get the video source URL from the main player // Get the video source URL from the main player
useEffect(() => { useEffect(() => {
let url = ''; if (videoRef.current && videoRef.current.querySelector("source")) {
if (videoRef.current && videoRef.current.querySelector('source')) { const source = videoRef.current.querySelector("source") as HTMLSourceElement;
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) { if (source && source.src) {
url = source.src; setVideoUrl(source.src);
} }
} else { } else {
// Fallback to sample video if needed // Fallback to sample video if needed
url = '/videos/sample-video.mp3'; setVideoUrl("/videos/sample-video-10m.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]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@ -139,7 +127,6 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
x-webkit-airplay="allow" x-webkit-airplay="allow"
preload="auto" preload="auto"
crossOrigin="anonymous" crossOrigin="anonymous"
poster={posterImage}
> >
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from "react";
import { generateThumbnail } from '@/lib/videoUtils'; import { generateThumbnail } from "@/lib/videoUtils";
import { formatDetailedTime } from '@/lib/timeUtils'; import { formatDetailedTime } from "@/lib/timeUtils";
import logger from '@/lib/logger'; import logger from "@/lib/logger";
import type { Segment } from '@/components/ClipSegments'; import type { Segment } from "@/components/ClipSegments";
// Represents a state of the editor for undo/redo // Represents a state of the editor for undo/redo
interface EditorState { interface EditorState {
@ -46,23 +46,48 @@ const useVideoTrimmer = () => {
useEffect(() => { useEffect(() => {
if (history.length > 0) { if (history.length > 0) {
// For debugging - moved to console.debug // For debugging - moved to console.debug
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`); console.debug(
`History state updated: ${history.length} entries, position: ${historyPosition}`
);
// Log actions in history to help debug undo/redo // Log actions in history to help debug undo/redo
const actions = history.map( 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 // 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 || ''; const lastAction = history[historyPosition]?.action || "";
if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') { if (lastAction !== "save" && lastAction !== "save_copy" && lastAction !== "save_segments") {
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
} }
} }
}, [history, historyPosition]); }, [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 // Initialize video event listeners
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@ -80,10 +105,10 @@ const useVideoTrimmer = () => {
// Create an initial segment that spans the entire video // Create an initial segment that spans the entire video
const initialSegment: Segment = { const initialSegment: Segment = {
id: 1, id: 1,
name: 'segment', name: "segment",
startTime: 0, startTime: 0,
endTime: video.duration, endTime: video.duration,
thumbnail: segmentThumbnail, thumbnail: segmentThumbnail
}; };
// Initialize history state with the full-length segment // Initialize history state with the full-length segment
@ -91,7 +116,7 @@ const useVideoTrimmer = () => {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [initialSegment], clipSegments: [initialSegment]
}; };
setHistory([initialState]); setHistory([initialState]);
@ -134,19 +159,19 @@ const useVideoTrimmer = () => {
}; };
// Add event listeners // Add event listeners
video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener('timeupdate', handleTimeUpdate); video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener('play', handlePlay); video.addEventListener("play", handlePlay);
video.addEventListener('pause', handlePause); video.addEventListener("pause", handlePause);
video.addEventListener('ended', handleEnded); video.addEventListener("ended", handleEnded);
return () => { return () => {
// Remove event listeners // Remove event listeners
video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener('timeupdate', handleTimeUpdate); video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener('play', handlePlay); video.removeEventListener("play", handlePlay);
video.removeEventListener('pause', handlePause); video.removeEventListener("pause", handlePause);
video.removeEventListener('ended', handleEnded); video.removeEventListener("ended", handleEnded);
}; };
}, []); }, []);
@ -159,7 +184,7 @@ const useVideoTrimmer = () => {
video.pause(); video.pause();
} else { } else {
// iOS Safari fix: Use the last seeked position if available // 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 // Only apply this if the video is not at the same position already
// This avoids unnecessary seeking which might cause playback issues // This avoids unnecessary seeking which might cause playback issues
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) { if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
@ -176,12 +201,12 @@ const useVideoTrimmer = () => {
.then(() => { .then(() => {
// Play started successfully // Play started successfully
// Reset the last seeked position after successfully starting playback // Reset the last seeked position after successfully starting playback
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.lastSeekedPosition = 0; window.lastSeekedPosition = 0;
} }
}) })
.catch((err) => { .catch((err) => {
console.error('Error starting playback:', err); console.error("Error starting playback:", err);
setIsPlaying(false); // Reset state if play failed setIsPlaying(false); // Reset state if play failed
}); });
} }
@ -201,7 +226,7 @@ const useVideoTrimmer = () => {
// Store the position in a global state accessible to iOS Safari // Store the position in a global state accessible to iOS Safari
// This ensures when play is pressed later, it remembers the position // This ensures when play is pressed later, it remembers the position
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.lastSeekedPosition = time; window.lastSeekedPosition = time;
} }
@ -214,7 +239,7 @@ const useVideoTrimmer = () => {
setIsPlaying(true); // Update state to reflect we're playing setIsPlaying(true); // Update state to reflect we're playing
}) })
.catch((err) => { .catch((err) => {
console.error('Error resuming playback:', err); console.error("Error resuming playback:", err);
setIsPlaying(false); setIsPlaying(false);
}); });
} }
@ -229,7 +254,7 @@ const useVideoTrimmer = () => {
trimEnd, trimEnd,
splitPoints: [...splitPoints], splitPoints: [...splitPoints],
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues 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 // Check if state is significantly different from last saved state
@ -314,16 +339,16 @@ const useVideoTrimmer = () => {
if (recordHistory) { if (recordHistory) {
// Use a small timeout to ensure the state is updated // Use a small timeout to ensure the state is updated
setTimeout(() => { setTimeout(() => {
saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end')); saveState(action || (isStart ? "adjust_trim_start" : "adjust_trim_end"));
}, 10); }, 10);
} }
} }
}; };
document.addEventListener('update-trim', handleTrimUpdate as EventListener); document.addEventListener("update-trim", handleTrimUpdate as EventListener);
return () => { return () => {
document.removeEventListener('update-trim', handleTrimUpdate as EventListener); document.removeEventListener("update-trim", handleTrimUpdate as EventListener);
}; };
}, []); }, []);
@ -335,11 +360,11 @@ const useVideoTrimmer = () => {
// Default to true to ensure all segment changes are recorded // Default to true to ensure all segment changes are recorded
const isSignificantChange = e.detail.recordHistory !== false; const isSignificantChange = e.detail.recordHistory !== false;
// Get the action type if provided // Get the action type if provided
const actionType = e.detail.action || 'update_segments'; const actionType = e.detail.action || "update_segments";
// Log the update details // Log the update details
logger.debug( 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 // Update segment state immediately for UI feedback
@ -359,7 +384,7 @@ const useVideoTrimmer = () => {
trimEnd, trimEnd,
splitPoints: [...splitPoints], splitPoints: [...splitPoints],
clipSegments: segmentsClone, 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 // Get the current history position to ensure we're using the latest value
@ -380,12 +405,16 @@ const useVideoTrimmer = () => {
// Ensure the historyPosition is updated to the correct position // Ensure the historyPosition is updated to the correct position
setHistoryPosition((prev) => { setHistoryPosition((prev) => {
const newPosition = prev + 1; 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; return newPosition;
}); });
}, 20); // Slightly increased delay to ensure state updates are complete }, 20); // Slightly increased delay to ensure state updates are complete
} else { } 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)`
);
} }
} }
}; };
@ -394,8 +423,8 @@ const useVideoTrimmer = () => {
const customEvent = e as CustomEvent; const customEvent = e as CustomEvent;
if ( if (
customEvent.detail && customEvent.detail &&
typeof customEvent.detail.time === 'number' && typeof customEvent.detail.time === "number" &&
typeof customEvent.detail.segmentId === 'number' typeof customEvent.detail.segmentId === "number"
) { ) {
// Get the time and segment ID from the event // Get the time and segment ID from the event
const timeToSplit = customEvent.detail.time; const timeToSplit = customEvent.detail.time;
@ -428,7 +457,7 @@ const useVideoTrimmer = () => {
name: `${segmentToSplit.name}-A`, name: `${segmentToSplit.name}-A`,
startTime: segmentToSplit.startTime, startTime: segmentToSplit.startTime,
endTime: timeToSplit, 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 // Create second half of the split segment - no thumbnail needed
@ -437,7 +466,7 @@ const useVideoTrimmer = () => {
name: `${segmentToSplit.name}-B`, name: `${segmentToSplit.name}-B`,
startTime: timeToSplit, startTime: timeToSplit,
endTime: segmentToSplit.endTime, 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 // Add the new segments
@ -448,14 +477,14 @@ const useVideoTrimmer = () => {
// Update state // Update state
setClipSegments(newSegments); setClipSegments(newSegments);
saveState('split_segment'); saveState("split_segment");
} }
}; };
// Handle delete segment event // Handle delete segment event
const handleDeleteSegment = async (e: Event) => { const handleDeleteSegment = async (e: Event) => {
const customEvent = e as CustomEvent; 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; const segmentId = customEvent.detail.segmentId;
// Find and remove the segment // Find and remove the segment
@ -468,10 +497,10 @@ const useVideoTrimmer = () => {
// No need to generate a thumbnail - we'll use dynamic colors // No need to generate a thumbnail - we'll use dynamic colors
const defaultSegment: Segment = { const defaultSegment: Segment = {
id: Date.now(), id: Date.now(),
name: 'segment', name: "segment",
startTime: 0, startTime: 0,
endTime: videoRef.current.duration, 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 // Reset the trim points as well
@ -483,32 +512,32 @@ const useVideoTrimmer = () => {
// Just update the segments normally // Just update the segments normally
setClipSegments(newSegments); setClipSegments(newSegments);
} }
saveState('delete_segment'); saveState("delete_segment");
} }
} }
}; };
document.addEventListener('update-segments', handleUpdateSegments as EventListener); document.addEventListener("update-segments", handleUpdateSegments as EventListener);
document.addEventListener('split-segment', handleSplitSegment as EventListener); document.addEventListener("split-segment", handleSplitSegment as EventListener);
document.addEventListener('delete-segment', handleDeleteSegment as EventListener); document.addEventListener("delete-segment", handleDeleteSegment as EventListener);
return () => { return () => {
document.removeEventListener('update-segments', handleUpdateSegments as EventListener); document.removeEventListener("update-segments", handleUpdateSegments as EventListener);
document.removeEventListener('split-segment', handleSplitSegment as EventListener); document.removeEventListener("split-segment", handleSplitSegment as EventListener);
document.removeEventListener('delete-segment', handleDeleteSegment as EventListener); document.removeEventListener("delete-segment", handleDeleteSegment as EventListener);
}; };
}, [clipSegments, duration]); }, [clipSegments, duration]);
// Handle trim start change // Handle trim start change
const handleTrimStartChange = (time: number) => { const handleTrimStartChange = (time: number) => {
setTrimStart(time); setTrimStart(time);
saveState('adjust_trim_start'); saveState("adjust_trim_start");
}; };
// Handle trim end change // Handle trim end change
const handleTrimEndChange = (time: number) => { const handleTrimEndChange = (time: number) => {
setTrimEnd(time); setTrimEnd(time);
saveState('adjust_trim_end'); saveState("adjust_trim_end");
}; };
// Handle split at current position // Handle split at current position
@ -534,7 +563,7 @@ const useVideoTrimmer = () => {
name: `Segment ${i + 1}`, name: `Segment ${i + 1}`,
startTime, startTime,
endTime, endTime,
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
}); });
startTime = endTime; startTime = endTime;
@ -542,7 +571,7 @@ const useVideoTrimmer = () => {
} }
setClipSegments(newSegments); setClipSegments(newSegments);
saveState('create_split_points'); saveState("create_split_points");
} }
}; };
@ -558,14 +587,14 @@ const useVideoTrimmer = () => {
// No need to generate thumbnails - we'll use dynamic colors // No need to generate thumbnails - we'll use dynamic colors
const defaultSegment: Segment = { const defaultSegment: Segment = {
id: Date.now(), id: Date.now(),
name: 'segment', name: "segment",
startTime: 0, startTime: 0,
endTime: duration, endTime: duration,
thumbnail: '', // Empty placeholder - we'll use dynamic colors instead thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
}; };
setClipSegments([defaultSegment]); setClipSegments([defaultSegment]);
saveState('reset_all'); saveState("reset_all");
}; };
// Handle undo // Handle undo
@ -578,7 +607,7 @@ const useVideoTrimmer = () => {
// Log segment details to help debug // Log segment details to help debug
logger.debug( logger.debug(
'Segment details after undo:', "Segment details after undo:",
previousState.clipSegments.map( previousState.clipSegments.map(
(seg) => (seg) =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
@ -592,7 +621,7 @@ const useVideoTrimmer = () => {
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments))); setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
setHistoryPosition(historyPosition - 1); setHistoryPosition(historyPosition - 1);
} else { } else {
logger.debug('Cannot undo: at earliest history position'); logger.debug("Cannot undo: at earliest history position");
} }
}; };
@ -606,7 +635,7 @@ const useVideoTrimmer = () => {
// Log segment details to help debug // Log segment details to help debug
logger.debug( logger.debug(
'Segment details after redo:', "Segment details after redo:",
nextState.clipSegments.map( nextState.clipSegments.map(
(seg) => (seg) =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}` `ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
@ -620,7 +649,7 @@ const useVideoTrimmer = () => {
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments))); setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
setHistoryPosition(historyPosition + 1); setHistoryPosition(historyPosition + 1);
} else { } else {
logger.debug('Cannot redo: at latest history position'); logger.debug("Cannot redo: at latest history position");
} }
}; };
@ -640,10 +669,10 @@ const useVideoTrimmer = () => {
setIsPlaying(false); setIsPlaying(false);
} else { } else {
// iOS Safari fix: Check for lastSeekedPosition // 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 // Only seek if the position is significantly different
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) { if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
logger.debug('handlePlay: Using lastSeekedPosition', window.lastSeekedPosition); console.log("handlePlay: Using lastSeekedPosition", window.lastSeekedPosition);
video.currentTime = window.lastSeekedPosition; video.currentTime = window.lastSeekedPosition;
} }
} }
@ -654,12 +683,12 @@ const useVideoTrimmer = () => {
.then(() => { .then(() => {
setIsPlaying(true); setIsPlaying(true);
// Reset lastSeekedPosition after successful play // Reset lastSeekedPosition after successful play
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.lastSeekedPosition = 0; window.lastSeekedPosition = 0;
} }
}) })
.catch((err) => { .catch((err) => {
console.error('Error playing video:', err); console.error("Error playing video:", err);
setIsPlaying(false); // Reset state if play failed setIsPlaying(false); // Reset state if play failed
}); });
} }
@ -681,28 +710,28 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving // Create the JSON data for saving
const saveData = { const saveData = {
type: 'save', type: "save",
segments: sortedSegments.map((segment) => ({ segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime), startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime), endTime: formatDetailedTime(segment.endTime)
})), }))
}; };
// Display JSON in alert (for demonstration purposes) // Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug('Saving data:', saveData); console.debug("Saving data:", saveData);
} }
// Mark as saved - no unsaved changes // Mark as saved - no unsaved changes
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// Debug message // Debug message
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug('Changes saved - reset unsaved changes flag'); console.debug("Changes saved - reset unsaved changes flag");
} }
// Save to history with special "save" action to mark saved state // 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 // In a real implementation, this would make a POST request to save the data
// logger.debug("Save data:", saveData); // logger.debug("Save data:", saveData);
@ -715,28 +744,28 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving as a copy // Create the JSON data for saving as a copy
const saveData = { const saveData = {
type: 'save_as_a_copy', type: "save_as_a_copy",
segments: sortedSegments.map((segment) => ({ segments: sortedSegments.map((segment) => ({
startTime: formatDetailedTime(segment.startTime), startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime), endTime: formatDetailedTime(segment.endTime)
})), }))
}; };
// Display JSON in alert (for demonstration purposes) // Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug('Saving data as copy:', saveData); console.debug("Saving data as copy:", saveData);
} }
// Mark as saved - no unsaved changes // Mark as saved - no unsaved changes
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// Debug message // Debug message
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug('Changes saved as copy - reset unsaved changes flag'); console.debug("Changes saved as copy - reset unsaved changes flag");
} }
// Save to history with special "save_copy" action to mark saved state // Save to history with special "save_copy" action to mark saved state
saveState('save_copy'); saveState("save_copy");
}; };
// Handle save segments individually action // Handle save segments individually action
@ -746,27 +775,27 @@ const useVideoTrimmer = () => {
// Create the JSON data for saving individual segments // Create the JSON data for saving individual segments
const saveData = { const saveData = {
type: 'save_segments', type: "save_segments",
segments: sortedSegments.map((segment) => ({ segments: sortedSegments.map((segment) => ({
name: segment.name, name: segment.name,
startTime: formatDetailedTime(segment.startTime), startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime), endTime: formatDetailedTime(segment.endTime)
})), }))
}; };
// Display JSON in alert (for demonstration purposes) // Display JSON in alert (for demonstration purposes)
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug('Saving data as segments:', saveData); console.debug("Saving data as segments:", saveData);
} }
// Mark as saved - no unsaved changes // Mark as saved - no unsaved changes
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
// Debug message // 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 // Save to history with special "save_segments" action to mark saved state
saveState('save_segments'); saveState("save_segments");
}; };
// Handle seeking with mobile check // Handle seeking with mobile check
@ -779,8 +808,10 @@ const useVideoTrimmer = () => {
// Check if device is mobile // Check if device is mobile
const isMobile = const isMobile =
typeof window !== 'undefined' && typeof window !== "undefined" &&
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(navigator.userAgent); /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
// Add videoInitialized state // Add videoInitialized state
const [videoInitialized, setVideoInitialized] = useState(false); const [videoInitialized, setVideoInitialized] = useState(false);
@ -814,9 +845,9 @@ const useVideoTrimmer = () => {
// If video is somehow paused, ensure it keeps playing // If video is somehow paused, ensure it keeps playing
if (video.paused) { if (video.paused) {
logger.debug('Ensuring playback continues to next segment'); logger.debug("Ensuring playback continues to next segment");
video.play().catch((err) => { video.play().catch((err) => {
console.error('Error continuing segment playback:', err); console.error("Error continuing segment playback:", err);
}); });
} }
} else { } else {
@ -824,12 +855,12 @@ const useVideoTrimmer = () => {
video.pause(); video.pause();
setIsPlayingSegments(false); setIsPlayingSegments(false);
setCurrentSegmentIndex(0); setCurrentSegmentIndex(0);
video.removeEventListener('timeupdate', handleSegmentsPlayback); video.removeEventListener("timeupdate", handleSegmentsPlayback);
} }
} }
}; };
video.addEventListener('timeupdate', handleSegmentsPlayback); video.addEventListener("timeupdate", handleSegmentsPlayback);
// Start playing if not already playing // Start playing if not already playing
if (video.paused && orderedSegments.length > 0) { if (video.paused && orderedSegments.length > 0) {
@ -838,7 +869,7 @@ const useVideoTrimmer = () => {
} }
return () => { return () => {
video.removeEventListener('timeupdate', handleSegmentsPlayback); video.removeEventListener("timeupdate", handleSegmentsPlayback);
}; };
}, [isPlayingSegments, currentSegmentIndex, clipSegments]); }, [isPlayingSegments, currentSegmentIndex, clipSegments]);
@ -847,15 +878,20 @@ const useVideoTrimmer = () => {
const handleSegmentIndexUpdate = (event: CustomEvent) => { const handleSegmentIndexUpdate = (event: CustomEvent) => {
const { segmentIndex } = event.detail; const { segmentIndex } = event.detail;
if (isPlayingSegments && segmentIndex !== currentSegmentIndex) { 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); setCurrentSegmentIndex(segmentIndex);
} }
}; };
document.addEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener); document.addEventListener("update-segment-index", handleSegmentIndexUpdate as EventListener);
return () => { return () => {
document.removeEventListener('update-segment-index', handleSegmentIndexUpdate as EventListener); document.removeEventListener(
"update-segment-index",
handleSegmentIndexUpdate as EventListener
);
}; };
}, [isPlayingSegments, currentSegmentIndex]); }, [isPlayingSegments, currentSegmentIndex]);
@ -884,11 +920,11 @@ const useVideoTrimmer = () => {
// Start playback with proper error handling // Start playback with proper error handling
video.play().catch((err) => { video.play().catch((err) => {
console.error('Error starting segments playback:', err); console.error("Error starting segments playback:", err);
setIsPlayingSegments(false); setIsPlayingSegments(false);
}); });
logger.debug('Starting playback of all segments continuously'); logger.debug("Starting playback of all segments continuously");
} }
}; };
@ -924,7 +960,7 @@ const useVideoTrimmer = () => {
handleSaveSegments, handleSaveSegments,
isMobile, isMobile,
videoInitialized, videoInitialized,
setVideoInitialized, setVideoInitialized
}; };
}; };

View File

@ -7,7 +7,7 @@ const logger = {
* Logs debug messages only in development environment * Logs debug messages only in development environment
*/ */
debug: (...args: any[]) => { debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === "development") {
console.debug(...args); console.debug(...args);
} }
}, },
@ -25,7 +25,7 @@ const logger = {
/** /**
* Always logs info messages * Always logs info messages
*/ */
info: (...args: any[]) => console.info(...args), info: (...args: any[]) => console.info(...args)
}; };
export default logger; 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) { async function throwIfResNotOk(res: Response) {
if (!res.ok) { if (!res.ok) {
@ -7,27 +7,31 @@ 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, { const res = await fetch(url, {
method, method,
headers: data ? { 'Content-Type': 'application/json' } : {}, headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,
credentials: 'include', credentials: "include"
}); });
await throwIfResNotOk(res); await throwIfResNotOk(res);
return res; return res;
} }
type UnauthorizedBehavior = 'returnNull' | 'throw'; type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> = export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, { 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; return null;
} }
@ -38,14 +42,14 @@ export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryF
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
queryFn: getQueryFn({ on401: 'throw' }), queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false, refetchInterval: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: Infinity, staleTime: Infinity,
retry: false, retry: false
}, },
mutations: { mutations: {
retry: false, retry: false
}, }
}, }
}); });

View File

@ -2,17 +2,17 @@
* Format seconds to HH:MM:SS.mmm format with millisecond precision * Format seconds to HH:MM:SS.mmm format with millisecond precision
*/ */
export const formatDetailedTime = (seconds: number): string => { 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 hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60); const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000); const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, '0'); const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, '0'); const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, '0'); const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, '0'); const formattedMilliseconds = String(milliseconds).padStart(3, "0");
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`; return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
}; };

View File

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

View File

@ -20,14 +20,17 @@ export const generateSolidColor = (time: number, duration: number): string => {
* Legacy function kept for compatibility * Legacy function kept for compatibility
* Now returns a data URL for a solid color square instead of a video thumbnail * 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) => { return new Promise((resolve) => {
// Create a small canvas for the solid color // 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.width = 10; // Much smaller - we only need a color
canvas.height = 10; canvas.height = 10;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
// Get the solid color based on time // Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration); const color = generateSolidColor(time, videoElement.duration);
@ -38,7 +41,7 @@ export const generateThumbnail = async (videoElement: HTMLVideoElement, time: nu
} }
// Convert to data URL (much smaller now) // 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); resolve(dataUrl);
}); });
}; };

View File

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

View File

@ -1,5 +1,5 @@
// API service for video trimming operations // API service for video trimming operations
import logger from '../lib/logger';
interface TrimVideoRequest { interface TrimVideoRequest {
segments: { segments: {
startTime: string; startTime: string;
@ -20,102 +20,18 @@ interface TrimVideoResponse {
// Helper function to simulate delay // Helper function to simulate delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Auto-save interface // For now, we'll use a mock API that returns a promise
interface AutoSaveRequest { // This can be replaced with actual API calls later
segments: { export const trimVideo = async (
startTime: string; mediaId: string,
endTime: string; data: TrimVideoRequest
name?: string; ): Promise<TrimVideoResponse> => {
}[];
}
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 { try {
// Attempt the real API call // Attempt the real API call
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, { const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data)
}); });
if (!response.ok) { if (!response.ok) {
@ -127,17 +43,17 @@ export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promis
const errorData = await response.json(); const errorData = await response.json();
return { return {
status: 400, status: 400,
error: errorData.error || 'An error occurred during processing', error: errorData.error || "An error occurred during processing",
msg: 'Video Processing Error', msg: "Video Processing Error",
url_redirect: '', url_redirect: ""
}; };
} catch (parseError) { } catch (parseError) {
// If can't parse response JSON, return generic error // If can't parse response JSON, return generic error
return { return {
status: 400, status: 400,
error: 'An error occurred during video processing', error: "An error occurred during video processing",
msg: 'Video Processing Error', msg: "Video Processing Error",
url_redirect: '', url_redirect: ""
}; };
} }
} else if (response.status !== 404) { } else if (response.status !== 404) {
@ -147,17 +63,17 @@ export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promis
const errorData = await response.json(); const errorData = await response.json();
return { return {
status: response.status, status: response.status,
error: errorData.error || 'An error occurred during processing', error: errorData.error || "An error occurred during processing",
msg: 'Video Processing Error', msg: "Video Processing Error",
url_redirect: '', url_redirect: ""
}; };
} catch (parseError) { } catch (parseError) {
// If can't parse response JSON, return generic error // If can't parse response JSON, return generic error
return { return {
status: response.status, status: response.status,
error: 'An error occurred during video processing', error: "An error occurred during video processing",
msg: 'Video Processing Error', msg: "Video Processing Error",
url_redirect: '', url_redirect: ""
}; };
} }
} else { } else {
@ -165,8 +81,8 @@ export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promis
await delay(1500); // Simulate 1.5 second server delay await delay(1500); // Simulate 1.5 second server delay
return { return {
status: 200, // Mock success status status: 200, // Mock success status
msg: 'Video Processed Successfully', // Updated per requirements msg: "Video Processed Successfully", // Updated per requirements
url_redirect: `./view?m=${mediaId}`, url_redirect: `./view?m=${mediaId}`
}; };
} }
} }
@ -175,17 +91,17 @@ export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promis
const jsonResponse = await response.json(); const jsonResponse = await response.json();
return { return {
status: 200, 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}`, url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
...jsonResponse, ...jsonResponse
}; };
} catch (error) { } catch (error) {
// For any fetch errors, return mock success response with delay // For any fetch errors, return mock success response with delay
await delay(1500); // Simulate 1.5 second server delay await delay(1500); // Simulate 1.5 second server delay
return { return {
status: 200, // Mock success status status: 200, // Mock success status
msg: 'Video Processed Successfully', // Consistent with requirements msg: "Video Processed Successfully", // Consistent with requirements
url_redirect: `./view?m=${mediaId}`, url_redirect: `./view?m=${mediaId}`
}; };
} }

View File

@ -1,32 +0,0 @@
/// <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,17 +32,11 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
output: { output: {
// Ensure CSS file has a predictable name and keep image assets // Ensure CSS file has a predictable name
assetFileNames: (assetInfo) => { assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'video-editor.css'; 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;
}
return assetInfo.name || 'asset-[hash][extname]';
}, },
// Inline small assets, emit larger ones
inlineDynamicImports: true,
// Add this to ensure the final bundle exposes React correctly // Add this to ensure the final bundle exposes React correctly
globals: { globals: {
'react': 'React', 'react': 'React',
@ -53,8 +47,6 @@ export default defineConfig({
// Output to Django's static directory // Output to Django's static directory
outDir: '../../../static/video_editor', outDir: '../../../static/video_editor',
emptyOutDir: true, emptyOutDir: true,
external: ['react', 'react-dom'], external: ['react', 'react-dom']
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
}, },
}); });

View File

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

View File

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

@ -1,337 +0,0 @@
# 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!

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