diff --git a/.gitignore b/.gitignore index 4dcc65d3..b103c72c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4 .DS_Store static/video_editor/videos/sample-video-10m.mp4 static/video_editor/videos/sample-video-10s.mp4 +frontend-tools/video-js/public/videos/sample-video-white.mp4 +frontend-tools/video-editor/client/public/videos/sample-video.mp3 +frontend-tools/chapters-editor/client/public/videos/sample-video.mp3 +static/chapters_editor/videos/sample-video.mp3 +static/video_editor/videos/sample-video.mp3 diff --git a/.prettierignore b/.prettierignore index f59ec20a..919dd65f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,3 @@ -* \ No newline at end of file +/templates/cms/* +/templates/*.html +*.scss \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d4b64fae --- /dev/null +++ b/.prettierrc @@ -0,0 +1,21 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 120, + "tabWidth": 4, + "useTabs": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "overrides": [ + { + "files": ["*.css", "*.scss"], + "options": { + "singleQuote": false + } + } + ] +} diff --git a/cms/version.py b/cms/version.py index 9a0a7cd0..9405b902 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "6.8.210" +VERSION = "7.1.0" diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index 7925836d..4e80e4c4 100755 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -30,7 +30,8 @@ fi # We should do this only for folders that have a different owner, since it is an expensive operation # Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934 -find /home/mediacms.io/mediacms ! \( -path "*.git*" \) -exec chown www-data:$TARGET_GID {} + +# Exclude package-lock.json files that may not exist or be removed during frontend setup +find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json" \) -exec chown www-data:$TARGET_GID {} + 2>/dev/null || true chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh diff --git a/deploy/scripts/build_and_deploy.sh b/deploy/scripts/build_and_deploy.sh index 53e6e387..fb4abdd2 100644 --- a/deploy/scripts/build_and_deploy.sh +++ b/deploy/scripts/build_and_deploy.sh @@ -1,5 +1,6 @@ #!/bin/bash # This script builds the video editor package and deploys the frontend assets to the static directory. +# How to run: sh deploy/scripts/build_and_deploy.sh # Exit on any error set -e @@ -12,9 +13,21 @@ cd frontend-tools/video-editor yarn build:django cd ../../ +# Build chapter editor package +echo "Building chapters editor package..." +cd frontend-tools/chapters-editor +yarn build:django +cd ../../ + +# Build video js package +echo "Building video js package..." +cd frontend-tools/video-js +yarn build:django +cd ../../ + # Run npm build in the frontend container echo "Building frontend assets..." -docker compose -f docker-compose-dev.yaml exec frontend npm run dist +docker compose -f docker-compose/docker-compose-dev-updated.yaml exec frontend npm run dist # Copy static assets to the static directory echo "Copying static assets..." @@ -22,6 +35,6 @@ cp -r frontend/dist/static/* static/ # Restart the web service echo "Restarting web service..." -docker compose -f docker-compose-dev.yaml restart web +docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web echo "Build and deployment completed successfully!" \ No newline at end of file diff --git a/docker-compose/docker-compose-dev-updated.yaml b/docker-compose/docker-compose-dev-updated.yaml index f055661f..32baa42e 100644 --- a/docker-compose/docker-compose-dev-updated.yaml +++ b/docker-compose/docker-compose-dev-updated.yaml @@ -33,55 +33,35 @@ services: volumes: - ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/ - frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules - - player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules - scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules - - npm_global:/home/node/.npm-global + - npm_cache:/home/node/.npm working_dir: /home/mediacms.io/mediacms/frontend/ command: > bash -c " - echo 'Setting up npm global directory...' && - mkdir -p /home/node/.npm-global && - chown -R node:node /home/node/.npm-global && - echo 'Setting up permissions...' && - chown -R node:node /home/mediacms.io/mediacms/frontend && - echo 'Cleaning up node_modules...' && - find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true && - find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true && - find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true && - chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules && - chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules && - chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules && - echo 'Switching to node user...' && - su node -c ' - export NPM_CONFIG_PREFIX=/home/node/.npm-global && - echo \"Setting up frontend...\" && - rm -f package-lock.json && - rm -f packages/player/package-lock.json && - rm -f packages/scripts/package-lock.json && - echo \"Installing dependencies...\" && - npm install --legacy-peer-deps && - echo \"Setting up workspaces...\" && - npm install -g npm@latest && + echo 'Checking dependencies...' && + if [ ! -f node_modules/.install-complete ]; then + echo 'First-time setup or dependencies changed, installing...' && + npm install --legacy-peer-deps --cache /home/node/.npm && cd packages/scripts && - npm install --legacy-peer-deps && - npm install rollup@2.79.1 --save-dev --legacy-peer-deps && - npm install typescript@4.9.5 --save-dev --legacy-peer-deps && - npm install tslib@2.6.2 --save --legacy-peer-deps && - npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps && - npm install --legacy-peer-deps && + npm install --legacy-peer-deps --cache /home/node/.npm && npm run build && cd ../.. && - cd packages/player && - npm install --legacy-peer-deps && - npm run build && - cd ../.. && - echo \"Starting development server...\" && - npm run start - '" + touch node_modules/.install-complete && + echo 'Dependencies installed successfully' + else + echo 'Dependencies already installed, skipping installation...' && + if [ ! -d packages/scripts/dist ]; then + echo 'Building scripts package...' && + cd packages/scripts && + npm run build && + cd ../.. + fi + fi && + echo 'Starting development server...' && + npm run start + " env_file: - ${PWD}/frontend/.env - environment: - - NPM_CONFIG_PREFIX=/home/node/.npm-global ports: - "8088:8088" depends_on: @@ -140,6 +120,5 @@ services: volumes: frontend_node_modules: - player_node_modules: scripts_node_modules: - npm_global: + npm_cache: diff --git a/files/methods.py b/files/methods.py index 53ab148e..a951507c 100644 --- a/files/methods.py +++ b/files/methods.py @@ -640,7 +640,7 @@ def handle_video_chapters(media, chapters): else: video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters) - return media.chapter_data + return {'chapters': media.chapter_data} def change_media_owner(media_id, new_user): diff --git a/files/models/media.py b/files/models/media.py index 65f1c5e9..01303875 100644 --- a/files/models/media.py +++ b/files/models/media.py @@ -630,7 +630,7 @@ class Media(models.Model): @property def trim_video_url(self): - if self.media_type not in ["video"]: + if self.media_type not in ["video", "audio"]: return None ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() @@ -642,7 +642,7 @@ class Media(models.Model): @property def trim_video_path(self): - if self.media_type not in ["video"]: + if self.media_type not in ["video", "audio"]: return None ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() diff --git a/files/models/video_data.py b/files/models/video_data.py index 9865544d..11ffe61d 100644 --- a/files/models/video_data.py +++ b/files/models/video_data.py @@ -12,40 +12,19 @@ class VideoChapterData(models.Model): class Meta: unique_together = ['media'] - def save(self, *args, **kwargs): - from .. import tasks - - is_new = self.pk is None - if is_new or (not is_new and self._check_data_changed()): - super().save(*args, **kwargs) - tasks.produce_video_chapters.delay(self.pk) - else: - super().save(*args, **kwargs) - - def _check_data_changed(self): - if self.pk: - old_instance = VideoChapterData.objects.get(pk=self.pk) - return old_instance.data != self.data - return False - @property def chapter_data(self): # ensure response is consistent data = [] - for item in self.data: - if item.get("start") and item.get("title"): - thumbnail = item.get("thumbnail") - if thumbnail: - thumbnail = helpers.url_from_path(thumbnail) - else: - thumbnail = "static/images/chapter_default.jpg" - data.append( - { - "start": item.get("start"), - "title": item.get("title"), - "thumbnail": thumbnail, + if self.data and isinstance(self.data, list): + for item in self.data: + if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"): + chapter_item = { + 'startTime': item.get("startTime"), + 'endTime': item.get("endTime"), + 'chapterTitle': item.get("chapterTitle"), } - ) + data.append(chapter_item) return data diff --git a/files/tasks.py b/files/tasks.py index 5d6eca12..586075ae 100644 --- a/files/tasks.py +++ b/files/tasks.py @@ -52,7 +52,6 @@ from .models import ( Subtitle, Tag, TranscriptionRequest, - VideoChapterData, VideoTrimRequest, ) @@ -950,45 +949,6 @@ def update_encoding_size(encoding_id): return False -@task(name="produce_video_chapters", queue="short_tasks") -def produce_video_chapters(chapter_id): - # this is not used - return False - chapter_object = VideoChapterData.objects.filter(id=chapter_id).first() - if not chapter_object: - return False - - media = chapter_object.media - video_path = media.media_file.path - output_folder = media.video_chapters_folder - - chapters = chapter_object.data - - width = 336 - height = 188 - - if not os.path.exists(output_folder): - os.makedirs(output_folder) - - results = [] - - for i, chapter in enumerate(chapters): - timestamp = chapter["start"] - title = chapter["title"] - - output_filename = f"thumbnail_{i:02d}.jpg" # noqa - output_path = os.path.join(output_folder, output_filename) - - command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path] - ret = run_command(command) # noqa - if os.path.exists(output_path) and get_file_type(output_path) == "image": - results.append({"start": timestamp, "title": title, "thumbnail": output_path}) - - chapter_object.data = results - chapter_object.save(update_fields=["data"]) - return True - - @task(name="post_trim_action", queue="short_tasks", soft_time_limit=600) def post_trim_action(friendly_token): """Perform post-processing actions after video trimming diff --git a/files/views/pages.py b/files/views/pages.py index 9041208f..d1b4aeff 100644 --- a/files/views/pages.py +++ b/files/views/pages.py @@ -244,8 +244,6 @@ def history(request): @csrf_exempt @login_required def video_chapters(request, friendly_token): - # this is not ready... - return False if not request.method == "POST": return HttpResponseRedirect("/") @@ -258,20 +256,26 @@ def video_chapters(request, friendly_token): return HttpResponseRedirect("/") try: - data = json.loads(request.body)["chapters"] + request_data = json.loads(request.body) + data = request_data.get("chapters") + if data is None: + return JsonResponse({'success': False, 'error': 'Request must contain "chapters" array'}, status=400) + chapters = [] for _, chapter_data in enumerate(data): - start_time = chapter_data.get('start') - title = chapter_data.get('title') - if start_time and title: + start_time = chapter_data.get('startTime') + end_time = chapter_data.get('endTime') + chapter_title = chapter_data.get('chapterTitle') + if start_time and end_time and chapter_title: chapters.append( { - 'start': start_time, - 'title': title, + 'startTime': start_time, + 'endTime': end_time, + 'chapterTitle': chapter_title, } ) except Exception as e: # noqa - return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400) + return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with startTime, endTime, chapterTitle'}, status=400) ret = handle_video_chapters(media, chapters) @@ -358,8 +362,6 @@ def publish_media(request): @login_required def edit_chapters(request): """Edit chapters""" - # not implemented yet - return False friendly_token = request.GET.get("m", "").strip() if not friendly_token: return HttpResponseRedirect("/") @@ -371,10 +373,11 @@ def edit_chapters(request): if not (request.user == media.user or is_mediacms_editor(request.user)): return HttpResponseRedirect("/") + chapters = media.chapter_data return render( request, "cms/edit_chapters.html", - {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token}, + {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters}, ) @@ -426,7 +429,7 @@ def edit_video(request): if not (request.user == media.user or is_mediacms_editor(request.user)): return HttpResponseRedirect("/") - if not media.media_type == "video": + if media.media_type not in ["video", "audio"]: messages.add_message(request, messages.INFO, "Media is not video") return HttpResponseRedirect(media.get_absolute_url()) diff --git a/frontend-tools/chapters-editor/.gitignore b/frontend-tools/chapters-editor/.gitignore new file mode 100644 index 00000000..1b13e1f8 --- /dev/null +++ b/frontend-tools/chapters-editor/.gitignore @@ -0,0 +1,15 @@ +node_modules +dist +.DS_Store +server/public +vite.config.ts.* +*.tar.gz +yt.readme.md +client/public/videos/sample-video.mp4 +client/public/videos/sample-video-30s.mp4 +client/public/videos/sample-video-37s.mp4 +videos/sample-video-37s.mp4 +client/public/videos/sample-video-30s.mp4 +client/public/videos/sample-video-1.mp4 +client/public/videos/sample-video-10m.mp4 +client/public/videos/sample-video-10s.mp4 diff --git a/frontend-tools/chapters-editor/.prettierignore b/frontend-tools/chapters-editor/.prettierignore new file mode 100644 index 00000000..e69de29b diff --git a/frontend-tools/chapters-editor/.vscode/settings.json b/frontend-tools/chapters-editor/.vscode/settings.json new file mode 100644 index 00000000..13734cb4 --- /dev/null +++ b/frontend-tools/chapters-editor/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "prettier.configPath": ".prettierrc" +} diff --git a/frontend-tools/chapters-editor/README.md b/frontend-tools/chapters-editor/README.md new file mode 100644 index 00000000..7237efd0 --- /dev/null +++ b/frontend-tools/chapters-editor/README.md @@ -0,0 +1,255 @@ +# MediaCMS Chapters Editor + +A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface. + +## Features + +- 📑 Create and manage video chapters with custom titles +- ⏱️ Precise timestamp controls for chapter start and end points +- ✂️ Split chapters and reorganize content +- 👁️ Preview chapters with jump-to navigation +- 🔄 Undo/redo support for all editing operations +- 🏷️ Chapter metadata editing (titles, descriptions) +- 💾 Save chapter data directly to MediaCMS +- 🎯 Timeline-based chapter visualization +- 📱 Responsive design for desktop and mobile + +## Use Cases + +- **Educational Content**: Add chapters to lectures and tutorials for better navigation +- **Entertainment**: Create chapters for movies, shows, or long-form content +- **Documentation**: Organize training videos and documentation with logical sections +- **Accessibility**: Improve content accessibility with structured navigation + +## Tech Stack + +- React 18 +- TypeScript +- Vite + +## Installation + +### Prerequisites + +- Node.js (v20+) - Use `nvm use 20` if you have nvm installed +- Yarn or npm package manager + +### Setup + +```bash +# Navigate to the Chapters Editor directory +cd frontend-tools/chapters-editor + +# Install dependencies with Yarn +yarn install + +# Or with npm +npm install +``` + +## Development + +The Chapters Editor can be run in two modes: + +### Standalone Development Mode + +This starts a local development server with hot reloading: + +```bash +# Start the development server with Yarn +yarn dev + +# Or with npm +npm run dev +``` + +### Frontend-only Development Mode + +If you want to work only on the frontend with MediaCMS backend: + +```bash +# Start frontend-only development with Yarn +yarn dev:frontend + +# Or with npm +npm run dev:frontend +``` + +## Building + +### For MediaCMS Integration + +To build the Chapters Editor for integration with MediaCMS: + +```bash +# Build for Django integration with Yarn +yarn build:django + +# Or with npm +npm run build:django +``` + +This will compile the editor and place the output in the MediaCMS static directory. + +### Standalone Build + +To build the editor as a standalone application: + +```bash +# Build for production with Yarn +yarn build + +# Or with npm +npm run build +``` + +## Deployment + +To deploy the Chapters Editor, you can use the build and deploy script (recommended): + +```bash +# Run the deployment script +sh deploy/scripts/build_and_deploy.sh +``` + +The build script handles all necessary steps for compiling and deploying the editor to MediaCMS. + +You can also deploy manually after building: + +```bash +# With Yarn +yarn deploy + +# Or with npm +npm run deploy +``` + +## Project Structure + +- `/client` - Frontend React application + - `/src` - Source code + - `/components` - React components for chapter editing + - `/hooks` - Custom React hooks for chapter management + - `/lib` - Utility functions and helpers + - `/services` - API services for MediaCMS integration + - `/styles` - CSS and style definitions +- `/shared` - Shared TypeScript types and utilities + +## API Integration + +The Chapters Editor interfaces with MediaCMS through a set of API endpoints for: + +- Retrieving video metadata and existing chapters +- Saving chapter data (timestamps, titles, descriptions) +- Validating chapter structure and timing +- Integration with MediaCMS user permissions + +### Chapter Data Format + +Chapters are stored in the following format: + +```json +{ + "chapters": [ + { + "id": "chapter-1", + "title": "Introduction", + "startTime": 0, + "endTime": 120, + "description": "Opening remarks and overview" + }, + { + "id": "chapter-2", + "title": "Main Content", + "startTime": 120, + "endTime": 600, + "description": "Core educational material" + } + ] +} +``` + +## Code Formatting + +To automatically format all source files using [Prettier](https://prettier.io): + +```bash +# Format all code in the src directory +npx prettier --write client/src/ + +# Or format specific file types +npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}" +``` + +You can also add this as a script in `package.json`: + +```json +"scripts": { + "format": "prettier --write client/src/" +} +``` + +Then run: + +```bash +yarn format +# or +npm run format +``` + +## Testing + +Run the test suite to ensure Chapters Editor functionality: + +```bash +# Run tests with Yarn +yarn test + +# Or with npm +npm test + +# Run tests in watch mode +yarn test:watch +npm run test:watch +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/chapter-enhancement` +3. Make your changes and add tests +4. Run the formatter: `yarn format` +5. Run tests: `yarn test` +6. Commit your changes: `git commit -m "Add chapter enhancement"` +7. Push to the branch: `git push origin feature/chapter-enhancement` +8. Submit a pull request + +## Troubleshooting + +### Common Issues + +**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions. + +**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded. + +**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files. + +### Debug Mode + +Enable debug mode for detailed logging: + +```bash +# Start with debug logging +DEBUG=true yarn dev +``` + +## Browser Support + +- Chrome/Chromium 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +## License + +This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details. diff --git a/frontend-tools/chapters-editor/client/index.html b/frontend-tools/chapters-editor/client/index.html new file mode 100644 index 00000000..ce800f49 --- /dev/null +++ b/frontend-tools/chapters-editor/client/index.html @@ -0,0 +1,34 @@ + + + + + + Chapters Editor + + + + +
+ + + diff --git a/frontend-tools/chapters-editor/client/public/audio-poster.jpg b/frontend-tools/chapters-editor/client/public/audio-poster.jpg new file mode 100644 index 00000000..0c6bb5c1 Binary files /dev/null and b/frontend-tools/chapters-editor/client/public/audio-poster.jpg differ diff --git a/frontend-tools/chapters-editor/client/src/App.tsx b/frontend-tools/chapters-editor/client/src/App.tsx new file mode 100644 index 00000000..1fefad96 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/App.tsx @@ -0,0 +1,184 @@ +import { formatDetailedTime } from './lib/timeUtils'; +import logger from './lib/logger'; +import VideoPlayer from '@/components/VideoPlayer'; +import TimelineControls from '@/components/TimelineControls'; +import EditingTools from '@/components/EditingTools'; +import ClipSegments from '@/components/ClipSegments'; +import MobilePlayPrompt from '@/components/IOSPlayPrompt'; +import useVideoChapters from '@/hooks/useVideoChapters'; +import { useEffect } from 'react'; + +const App = () => { + const { + videoRef, + currentTime, + duration, + isPlaying, + setIsPlaying, + isMuted, + trimStart, + trimEnd, + splitPoints, + zoomLevel, + clipSegments, + selectedSegmentId, + hasUnsavedChanges, + historyPosition, + history, + handleTrimStartChange, + handleTrimEndChange, + handleZoomChange, + handleMobileSafeSeek, + handleSplit, + handleReset, + handleUndo, + handleRedo, + toggleMute, + handleSegmentUpdate, + handleChapterSave, + handleSelectedSegmentChange, + isMobile, + videoInitialized, + setVideoInitialized, + initializeSafariIfNeeded, + } = useVideoChapters(); + + const handlePlay = async () => { + if (!videoRef.current) return; + + const video = videoRef.current; + + // If already playing, just pause the video + if (isPlaying) { + video.pause(); + setIsPlaying(false); + logger.debug('Video paused'); + return; + } + + // Safari: Try to initialize if needed before playing + if (duration === 0) { + const initialized = await initializeSafariIfNeeded(); + if (initialized) { + // Wait a moment for initialization to complete + setTimeout(() => handlePlay(), 200); + return; + } + } + + // Start playing - no boundary checking, play through entire timeline + video + .play() + .then(() => { + setIsPlaying(true); + setVideoInitialized(true); + logger.debug('Continuous playback started from:', formatDetailedTime(video.currentTime)); + }) + .catch((err) => { + console.error('Error playing video:', err); + }); + }; + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Don't handle keyboard shortcuts if user is typing in an input field + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + switch (event.code) { + case 'Space': + event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation) + handlePlay(); + break; + case 'ArrowLeft': + event.preventDefault(); + if (videoRef.current) { + const newTime = Math.max(currentTime - 10, 0); + handleMobileSafeSeek(newTime); + logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime)); + } + break; + case 'ArrowRight': + event.preventDefault(); + if (videoRef.current) { + const newTime = Math.min(currentTime + 10, duration); + handleMobileSafeSeek(newTime); + logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime)); + } + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]); + + return ( +
+ + +
+ {/* Video Player */} + + + {/* Editing Tools */} + 0} + canRedo={historyPosition < history.length - 1} + /> + + {/* Timeline Controls */} + + + {/* Clip Segments */} + +
+
+ ); +}; + +export default App; diff --git a/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts b/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts new file mode 100644 index 00000000..1c13a96b --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/audioPosterUrl.ts @@ -0,0 +1,6 @@ +// Import the audio poster image as a module +// Vite will handle this and provide the correct URL +import audioPosterJpg from '../../public/audio-poster.jpg'; + +export const AUDIO_POSTER_URL = audioPosterJpg; + diff --git a/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg b/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg new file mode 100644 index 00000000..777ec21d --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/pause-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg new file mode 100644 index 00000000..8d4a4640 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/play-from-beginning-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg new file mode 100644 index 00000000..3e298784 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/play-from-start-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/play-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-icon.svg new file mode 100644 index 00000000..95cbc63d --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/play-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg b/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg new file mode 100644 index 00000000..5549690c --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/play-tooltip-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg new file mode 100644 index 00000000..3b602d88 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-end-new-cutaway.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg new file mode 100644 index 00000000..3b602d88 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-end-new.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-end.svg b/frontend-tools/chapters-editor/client/src/assets/segment-end.svg new file mode 100644 index 00000000..3c74943c --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-end.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg new file mode 100644 index 00000000..20dc2a5c --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-start-new-cutaway.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg new file mode 100644 index 00000000..2b7d9751 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-start-new.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/assets/segment-start.svg b/frontend-tools/chapters-editor/client/src/assets/segment-start.svg new file mode 100644 index 00000000..f2bd3ccf --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/assets/segment-start.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx new file mode 100644 index 00000000..4c560642 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/ClipSegments.tsx @@ -0,0 +1,93 @@ +import { formatTime, formatLongTime } from '@/lib/timeUtils'; +import '../styles/ClipSegments.css'; + +export interface Segment { + id: number; + chapterTitle: string; + startTime: number; + endTime: number; +} + +interface ClipSegmentsProps { + segments: Segment[]; + selectedSegmentId?: number | null; +} + +const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => { + // Sort segments by startTime + const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); + + // Handle delete segment click + const handleDeleteSegment = (segmentId: number) => { + // Create and dispatch the delete event + const deleteEvent = new CustomEvent('delete-segment', { + detail: { segmentId }, + }); + document.dispatchEvent(deleteEvent); + }; + + // Generate the same color background for a segment as shown in the timeline + const getSegmentColorClass = (index: number) => { + // Return CSS class based on index modulo 8 + // This matches the CSS nth-child selectors in the timeline + return `segment-default-color segment-color-${(index % 8) + 1}`; + }; + + // Get selected segment + const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId); + + return ( +
+

Chapters

+ + {sortedSegments.map((segment, index) => ( +
+
+
+
+ {segment.chapterTitle ? ( + {segment.chapterTitle} + ) : ( + Chapter {index + 1} + )} +
+
+ {formatTime(segment.startTime)} - {formatTime(segment.endTime)} +
+
+ Duration: {formatLongTime(segment.endTime - segment.startTime)} +
+
+
+
+ +
+
+ ))} + + {sortedSegments.length === 0 && ( +
+ No chapters created yet. Use the split button to create chapter segments. +
+ )} +
+ ); +}; + +export default ClipSegments; diff --git a/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx b/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx new file mode 100644 index 00000000..f06ee319 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/EditingTools.tsx @@ -0,0 +1,219 @@ +import '../styles/EditingTools.css'; +import { useEffect, useState } from 'react'; +import logger from '@/lib/logger'; + +interface EditingToolsProps { + onSplit: () => void; + onReset: () => void; + onUndo: () => void; + onRedo: () => void; + onPlay: () => void; + canUndo: boolean; + canRedo: boolean; + isPlaying?: boolean; +} + +const EditingTools = ({ + onSplit, + onReset, + onUndo, + onRedo, + onPlay, + canUndo, + canRedo, + isPlaying = false, +}: EditingToolsProps) => { + const [isSmallScreen, setIsSmallScreen] = useState(false); + + useEffect(() => { + const checkScreenSize = () => { + setIsSmallScreen(window.innerWidth <= 640); + }; + + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => window.removeEventListener('resize', checkScreenSize); + }, []); + + // Handle play button click with iOS fix + const handlePlay = () => { + // Ensure lastSeekedPosition is used when play is clicked + if (typeof window !== 'undefined') { + logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition); + } + + // Call the original handler + onPlay(); + }; + + return ( +
+
+ {/* Left side - Play buttons group */} +
+ + {/* Play Preview button */} + {/* */} + + {/* Standard Play button */} + + + {/* Segments Playback message (replaces play button during segments playback) */} + {/* {isPlayingSegments && !isSmallScreen && ( +
+ + + + + + Preview Mode +
+ )} */} + + {/* Preview mode message (replaces play button) */} + {/* {isPreviewMode && ( +
+ + + + + + Preview Mode +
+ )} */} +
+ + {/* Right side - Editing tools */} +
+ + +
+ +
+
+
+ ); +}; + +export default EditingTools; diff --git a/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx b/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx new file mode 100644 index 00000000..48dc6ac9 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/IOSPlayPrompt.tsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from 'react'; +import '../styles/IOSPlayPrompt.css'; + +interface MobilePlayPromptProps { + videoRef: React.RefObject; + onPlay: () => void; +} + +const MobilePlayPrompt: React.FC = ({ 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 ( +
+
+ +
+
+ ); +}; + +export default MobilePlayPrompt; diff --git a/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx b/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx new file mode 100644 index 00000000..e5bd3f48 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/IOSVideoPlayer.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState, useRef } from 'react'; +import { formatTime } from '@/lib/timeUtils'; +import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl'; +import '../styles/IOSVideoPlayer.css'; + +interface IOSVideoPlayerProps { + videoRef: React.RefObject; + currentTime: number; + duration: number; +} + +const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => { + const [videoUrl, setVideoUrl] = useState(''); + const [iosVideoRef, setIosVideoRef] = useState(null); + const [posterImage, setPosterImage] = useState(undefined); + + // Refs for hold-to-continue functionality + const incrementIntervalRef = useRef(null); + const decrementIntervalRef = useRef(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 ( +
+ {/* Current Time / Duration Display */} +
+ + {formatTime(currentTime)} / {formatTime(duration)} + +
+ + {/* iOS-optimized Video Element with Native Controls */} + + + {/* iOS Video Skip Controls */} +
+ + +
+ + {/* iOS Fine Control Buttons */} +
+ + +
+ +
+

This player uses native iOS controls for better compatibility with iOS devices.

+
+
+ ); +}; + +export default IOSVideoPlayer; diff --git a/frontend-tools/chapters-editor/client/src/components/Modal.tsx b/frontend-tools/chapters-editor/client/src/components/Modal.tsx new file mode 100644 index 00000000..ae7e2aa1 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/Modal.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import '../styles/Modal.css'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + actions?: React.ReactNode; +} + +const Modal: React.FC = ({ 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 ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ +
{children}
+ + {actions &&
{actions}
} +
+
+ ); +}; + +export default Modal; diff --git a/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx new file mode 100644 index 00000000..f7e79066 --- /dev/null +++ b/frontend-tools/chapters-editor/client/src/components/TimelineControls.tsx @@ -0,0 +1,4089 @@ +import { useRef, useEffect, useState, useCallback } from 'react'; +import { formatTime, formatDetailedTime } from '../lib/timeUtils'; +import { generateSolidColor } from '../lib/videoUtils'; +import { Segment } from './ClipSegments'; +import Modal from './Modal'; +import { autoSaveVideo } from '../services/videoApi'; +import logger from '../lib/logger'; +import '../styles/TimelineControls.css'; +import '../styles/TwoRowTooltip.css'; +import playIcon from '../assets/play-icon.svg'; +import pauseIcon from '../assets/pause-icon.svg'; +import playFromBeginningIcon from '../assets/play-from-beginning-icon.svg'; +import segmentEndIcon from '../assets/segment-end-new.svg'; +import segmentStartIcon from '../assets/segment-start-new.svg'; +import segmentNewStartIcon from '../assets/segment-start-new-cutaway.svg'; +import segmentNewEndIcon from '../assets/segment-end-new-cutaway.svg'; + +// Add styles for the media page link +const mediaPageLinkStyles = { + color: '#007bff', + textDecoration: 'none', + fontWeight: 'bold', + '&:hover': { + textDecoration: 'underline', + color: '#0056b3', + }, +} as const; + +interface TimelineControlsProps { + currentTime: number; + duration: number; + thumbnails: string[]; + trimStart: number; + trimEnd: number; + splitPoints: number[]; + zoomLevel: number; + clipSegments: Segment[]; + selectedSegmentId?: number | null; + onSelectedSegmentChange?: (segmentId: number | null) => void; + onSegmentUpdate?: (segmentId: number, updates: Partial) => void; + onChapterSave?: (chapters: { chapterTitle: string; from: string; to: string }[]) => void; + onTrimStartChange: (time: number) => void; + onTrimEndChange: (time: number) => void; + onZoomChange: (level: number) => void; + onSeek: (time: number) => void; + videoRef: React.RefObject; + hasUnsavedChanges?: boolean; + isIOSUninitialized?: boolean; + isPlaying: boolean; + setIsPlaying: (playing: boolean) => void; + onPlayPause: () => void; + isPlayingSegments?: boolean; +} + +// Function to calculate and constrain tooltip position to keep it on screen +// Uses smooth transitions instead of hard breakpoints to eliminate jumping +const constrainTooltipPosition = (positionPercent: number) => { + // Smooth transition zones instead of hard breakpoints + const leftTransitionStart = 0; + const leftTransitionEnd = 15; + const rightTransitionStart = 75; + const rightTransitionEnd = 100; + + let leftValue: string; + let transform: string; + + if (positionPercent <= leftTransitionEnd) { + // Left side: smooth transition from center to left-aligned + if (positionPercent <= leftTransitionStart) { + // Fully left-aligned + leftValue = '0%'; + transform = 'none'; + } else { + // Smooth transition zone + const transitionProgress = + (positionPercent - leftTransitionStart) / (leftTransitionEnd - leftTransitionStart); + const translateAmount = -50 * transitionProgress; // Gradually reduce from 0% to -50% + leftValue = `${positionPercent}%`; + transform = `translateX(${translateAmount}%)`; + } + } else if (positionPercent >= rightTransitionStart) { + // Right side: smooth transition from center to right-aligned + if (positionPercent >= rightTransitionEnd) { + // Fully right-aligned + leftValue = '100%'; + transform = 'translateX(-100%)'; + } else { + // Smooth transition zone + const transitionProgress = + (positionPercent - rightTransitionStart) / (rightTransitionEnd - rightTransitionStart); + const translateAmount = -50 - 50 * transitionProgress; // Gradually change from -50% to -100% + leftValue = `${positionPercent}%`; + transform = `translateX(${translateAmount}%)`; + } + } else { + // Center zone: normal centered positioning + leftValue = `${positionPercent}%`; + transform = 'translateX(-50%)'; + } + + return { left: leftValue, transform }; +}; + +const TimelineControls = ({ + currentTime, + duration, + trimStart, + trimEnd, + splitPoints, + zoomLevel, + clipSegments, + selectedSegmentId: externalSelectedSegmentId, + onSelectedSegmentChange, + onSegmentUpdate, + onChapterSave, + onTrimStartChange, + onTrimEndChange, + onZoomChange, + onSeek, + videoRef, + hasUnsavedChanges = false, + isIOSUninitialized = false, + isPlaying, + setIsPlaying, + onPlayPause, // Add this prop + isPlayingSegments = false, +}: TimelineControlsProps) => { + // Helper function to generate proper chapter name based on chronological position + const generateChapterName = (newSegmentStartTime: number, existingSegments: Segment[]): string => { + // Create a temporary array with all segments including the new one + const allSegments = [...existingSegments, { startTime: newSegmentStartTime } as Segment]; + // Sort by start time to find chronological position + const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); + // Find the index of our new segment + const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime); + return `Chapter ${chapterIndex + 1}`; + }; + + const timelineRef = useRef(null); + const leftHandleRef = useRef(null); + const rightHandleRef = useRef(null); + // Use external selectedSegmentId if provided, otherwise use internal state + const [internalSelectedSegmentId, setInternalSelectedSegmentId] = useState(null); + const selectedSegmentId = + externalSelectedSegmentId !== undefined ? externalSelectedSegmentId : internalSelectedSegmentId; + const setSelectedSegmentId = (segmentId: number | null) => { + if (onSelectedSegmentChange) { + onSelectedSegmentChange(segmentId); + } else { + setInternalSelectedSegmentId(segmentId); + } + }; + const [showEmptySpaceTooltip, setShowEmptySpaceTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + const [clickedTime, setClickedTime] = useState(0); + const [isZoomDropdownOpen, setIsZoomDropdownOpen] = useState(false); + const [availableSegmentDuration, setAvailableSegmentDuration] = useState(30); // Default 30 seconds + const [isPlayingSegment, setIsPlayingSegment] = useState(false); + const [activeSegment, setActiveSegment] = useState(null); + const [displayTime, setDisplayTime] = useState(0); + // Track when we should continue playing (clicking play after boundary stop) + const [continuePastBoundary, setContinuePastBoundary] = useState(false); + + // Reference for the scrollable container + const scrollContainerRef = useRef(null); + + // Chapter editor state + const [editingChapterTitle, setEditingChapterTitle] = useState(''); + const [chapterHasUnsavedChanges, setChapterHasUnsavedChanges] = useState(false); + + // Sort segments by startTime for chapter editor + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId); + + // Auto-save related state + const [lastAutoSaveTime, setLastAutoSaveTime] = useState(''); + const [isAutoSaving, setIsAutoSaving] = useState(false); + const autoSaveTimerRef = useRef(null); + const clipSegmentsRef = useRef(clipSegments); + + + // Keep clipSegmentsRef updated + useEffect(() => { + clipSegmentsRef.current = clipSegments; + }, [clipSegments]); + + + // Auto-save function + const performAutoSave = useCallback(async () => { + try { + setIsAutoSaving(true); + + // Format segments data for API request - use ref to get latest segments and sort by start time + const chapters = clipSegmentsRef.current + .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically + .map((chapter) => ({ + startTime: formatDetailedTime(chapter.startTime), + endTime: formatDetailedTime(chapter.endTime), + chapterTitle: chapter.chapterTitle, + })); + + logger.debug('chapters', chapters); + + const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; + // For testing, use '1234' if no mediaId is available + const finalMediaId = mediaId || '1234'; + + logger.debug('mediaId', finalMediaId); + + if (!finalMediaId || chapters.length === 0) { + logger.debug('No mediaId or segments, skipping auto-save'); + setIsAutoSaving(false); + return; + } + + logger.debug('Auto-saving segments:', { mediaId: finalMediaId, chapters }); + + const response = await autoSaveVideo(finalMediaId, { chapters }); + + if (response.success === true) { + logger.debug('Auto-save successful'); + // Format the timestamp for display + const date = new Date(response.updated_at || new Date().toISOString()); + const formattedTime = date + .toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + .replace(',', ''); + + setLastAutoSaveTime(formattedTime); + logger.debug('Auto-save successful:', formattedTime); + } else { + logger.error('Auto-save failed: (TimelineControls.tsx)'); + } + } catch (error) { + logger.error('Auto-save error: (TimelineControls.tsx)', error); + } finally { + setIsAutoSaving(false); + } + }, []); + + // Schedule auto-save with debounce + const scheduleAutoSave = useCallback(() => { + // Clear any existing timer + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + logger.debug('Cleared existing auto-save timer'); + } + + logger.debug('Scheduling new auto-save in 1 second...'); + + // Schedule new auto-save after 1 second of inactivity + const timerId = setTimeout(() => { + logger.debug('Auto-save timer fired! Calling performAutoSave...'); + performAutoSave(); + }, 1000); + + autoSaveTimerRef.current = timerId; + logger.debug('Timer ID set:', timerId); + }, [performAutoSave]); + + // Update editing title when selected segment changes + useEffect(() => { + if (selectedSegment) { + // Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.) + const isDefaultChapterName = selectedSegment.chapterTitle && + /^Chapter \d+$/.test(selectedSegment.chapterTitle); + + // If it's a default name, show empty string so placeholder appears + // If it's a custom title, show the actual title + setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || '')); + } else { + setEditingChapterTitle(''); + } + }, [selectedSegmentId, selectedSegment]); + + // Handle chapter title change + const handleChapterTitleChange = (value: string) => { + setEditingChapterTitle(value); + setChapterHasUnsavedChanges(true); + + // Update the segment immediately + if (selectedSegmentId && onSegmentUpdate) { + onSegmentUpdate(selectedSegmentId, { chapterTitle: value }); + } + }; + + // Handle save chapters + /* const handleSaveChapters = () => { + if (!onChapterSave) return; + + // Convert segments to chapter format + const chapters = sortedSegments.map((segment, index) => ({ + name: segment.chapterTitle || `Chapter ${index + 1}`, + from: formatDetailedTime(segment.startTime), + to: formatDetailedTime(segment.endTime), + })); + + onChapterSave(chapters); + setChapterHasUnsavedChanges(false); + }; */ + + // Helper function for time adjustment buttons to maintain playback state + /* const handleTimeAdjustment = (offsetSeconds: number) => (e: React.MouseEvent) => { + e.stopPropagation(); + + // Calculate new time based on offset (positive or negative) + const newTime = + offsetSeconds < 0 + ? Math.max(0, clickedTime + offsetSeconds) // For negative offsets (going back) + : Math.min(duration, clickedTime + offsetSeconds); // For positive offsets (going forward) + + // Save the current playing state before seeking + const wasPlaying = isPlayingSegment; + + // Seek to the new time + onSeek(newTime); + + // Update both clicked time and display time + setClickedTime(newTime); + setDisplayTime(newTime); + + // Resume playback if it was playing before + if (wasPlaying && videoRef.current) { + videoRef.current.play(); + setIsPlayingSegment(true); + } + }; */ + + // Enhanced helper for continuous time adjustment when button is held down + const handleContinuousTimeAdjustment = (offsetSeconds: number) => { + // Fixed adjustment amount - exactly 50ms each time + const adjustmentValue = offsetSeconds; + // Hold timer for continuous adjustment + let holdTimer: NodeJS.Timeout | null = null; + let continuousTimer: NodeJS.Timeout | null = null; + // Store the last time value to correctly calculate the next increment + let lastTimeValue = clickedTime; + + // Function to perform time adjustment + const adjustTime = () => { + // Calculate new time based on fixed offset (positive or negative) + const newTime = + adjustmentValue < 0 + ? Math.max(0, lastTimeValue + adjustmentValue) // For negative offsets (going back) + : Math.min(duration, lastTimeValue + adjustmentValue); // For positive offsets (going forward) + + // Update our last time value for next adjustment + lastTimeValue = newTime; + + // Save the current playing state before seeking + const wasPlaying = isPlayingSegment; + + // Seek to the new time + onSeek(newTime); + + // Update both clicked time and display time + setClickedTime(newTime); + setDisplayTime(newTime); + + // Update tooltip position + if (timelineRef.current) { + const rect = timelineRef.current.getBoundingClientRect(); + const positionPercent = (newTime / duration) * 100; + const xPos = rect.left + rect.width * (positionPercent / 100); + setTooltipPosition({ + x: xPos, + y: rect.top - 10, + }); + + // Find if we're in a segment at the new time + const segmentAtTime = clipSegments.find((seg) => newTime >= seg.startTime && newTime <= seg.endTime); + + if (segmentAtTime) { + // Show segment tooltip + setSelectedSegmentId(segmentAtTime.id); + setShowEmptySpaceTooltip(false); + } else { + // Show cutaway tooltip + setSelectedSegmentId(null); + const availableSpace = calculateAvailableSpace(newTime); + setAvailableSegmentDuration(availableSpace); + setShowEmptySpaceTooltip(true); + } + } + + // Resume playback if it was playing before + if (wasPlaying && videoRef.current) { + videoRef.current.play(); + setIsPlayingSegment(true); + } + }; + + // Return mouse event handlers with touch support + return { + onMouseDown: (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + // Update the initial last time value + lastTimeValue = clickedTime; + + // Perform initial adjustment + adjustTime(); + + // Start continuous adjustment after 1.5s hold + holdTimer = setTimeout(() => { + // After 1.5s delay, start adjusting at a slower pace (every 200ms) + continuousTimer = setInterval(adjustTime, 200); + }, 750); + + // Add mouse up and leave handlers to document to ensure we catch the release + const clearTimers = () => { + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + if (continuousTimer) { + clearInterval(continuousTimer); + continuousTimer = null; + } + document.removeEventListener('mouseup', clearTimers); + document.removeEventListener('mouseleave', clearTimers); + }; + + document.addEventListener('mouseup', clearTimers); + document.addEventListener('mouseleave', clearTimers); + }, + onTouchStart: (e: React.TouchEvent) => { + e.stopPropagation(); + e.preventDefault(); + 21; + + // Update the initial last time value + lastTimeValue = clickedTime; + + // Perform initial adjustment + adjustTime(); + + // Start continuous adjustment after 1.5s hold + holdTimer = setTimeout(() => { + // After 1.5s delay, start adjusting at a slower pace (every 200ms) + continuousTimer = setInterval(adjustTime, 200); + }, 750); + + // Add touch end handler to ensure we catch the release + const clearTimers = () => { + if (holdTimer) { + clearTimeout(holdTimer); + holdTimer = null; + } + if (continuousTimer) { + clearInterval(continuousTimer); + continuousTimer = null; + } + document.removeEventListener('touchend', clearTimers); + document.removeEventListener('touchcancel', clearTimers); + }; + + document.addEventListener('touchend', clearTimers); + document.addEventListener('touchcancel', clearTimers); + }, + onClick: (e: React.MouseEvent) => { + // This prevents the click event from firing twice + e.stopPropagation(); + }, + }; + }; + + // Modal states + const [showSaveChaptersModal, setShowSaveChaptersModal] = useState(false); + const [showProcessingModal, setShowProcessingModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [redirectUrl, setRedirectUrl] = useState(''); + const [saveType, setSaveType] = useState<'chapters'>('chapters'); + + // Calculate positions as percentages + const currentTimePercent = duration > 0 ? (currentTime / duration) * 100 : 0; + const trimStartPercent = duration > 0 ? (trimStart / duration) * 100 : 0; + const trimEndPercent = duration > 0 ? (trimEnd / duration) * 100 : 0; + + // No need for an extra effect here as we handle displayTime updates in the segment playback effect + + // Save Chapters handler + const handleSaveChaptersConfirm = async () => { + // Close confirmation modal and show processing modal + setShowSaveChaptersModal(false); + setShowProcessingModal(true); + setSaveType('chapters'); + + try { + // Format chapters data for API request - sort by start time first + const chapters = clipSegments + .filter((segment) => segment.chapterTitle && segment.chapterTitle.trim()) + .sort((a, b) => a.startTime - b.startTime) // Sort by start time chronologically + .map((segment) => ({ + chapterTitle: segment.chapterTitle || `Chapter ${segment.id}`, + from: formatDetailedTime(segment.startTime), + to: formatDetailedTime(segment.endTime), + })); + + // Allow saving even when no chapters exist (will send empty array) + // Call the onChapterSave function if provided + if (onChapterSave) { + await onChapterSave(chapters); + setShowProcessingModal(false); + + if (chapters.length === 0) { + setSuccessMessage('All chapters cleared successfully!'); + } else { + setSuccessMessage('Chapters saved successfully!'); + } + + // Set redirect URL to media page + const mediaId = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.mediaId) || null; + if (mediaId) { + setRedirectUrl(`/view?m=${mediaId}`); + } + + setShowSuccessModal(true); + } else { + setErrorMessage('Chapter save function not available'); + setShowErrorModal(true); + setShowProcessingModal(false); + } + } catch (error) { + logger.error('Error saving chapters:', error); + setShowProcessingModal(false); + + // Set error message and show error modal + const errorMsg = error instanceof Error ? error.message : 'An error occurred while saving chapters'; + logger.debug('Save chapters error (exception):', errorMsg); + setErrorMessage(errorMsg); + setShowErrorModal(true); + } + }; + + // Auto-scroll and update tooltip position when seeking to a different time + useEffect(() => { + if (scrollContainerRef.current && timelineRef.current && zoomLevel > 1) { + const containerWidth = scrollContainerRef.current.clientWidth; + const timelineWidth = timelineRef.current.clientWidth; + const markerPosition = (currentTime / duration) * timelineWidth; + + // Calculate the position where we want the marker to be visible + // (center of the viewport when possible) + const desiredScrollPosition = Math.max(0, markerPosition - containerWidth / 2); + + // Smooth scroll to the desired position + scrollContainerRef.current.scrollTo({ + left: desiredScrollPosition, + behavior: 'smooth', + }); + + // Update tooltip position to stay with the marker + const rect = timelineRef.current.getBoundingClientRect(); + + // Calculate the visible position of the marker after scrolling + const containerRect = scrollContainerRef.current.getBoundingClientRect(); + const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; + const markerX = visibleTimelineLeft + (currentTimePercent / 100) * rect.width; + + // Only update if we have a tooltip showing + if (selectedSegmentId !== null || showEmptySpaceTooltip) { + setTooltipPosition({ + x: markerX, + y: rect.top - 10, + }); + setClickedTime(currentTime); + } + } + }, [currentTime, zoomLevel, duration, selectedSegmentId, showEmptySpaceTooltip, currentTimePercent]); + + // Effect to check active segment boundaries during playback - DISABLED for continuous playback + useEffect(() => { + // Boundary checking disabled - allow continuous playback through all segments + logger.debug('Segment boundary checking disabled - continuous playback enabled'); + return; + }, [activeSegment, isPlayingSegment, continuePastBoundary, clipSegments]); + + // Update display time and check for transitions between segments and empty spaces + useEffect(() => { + // Always update display time to match current video time when playing + if (videoRef.current) { + // If video is playing, always update the displayed time in the tooltip + if (!videoRef.current.paused) { + setDisplayTime(currentTime); + + // Also update clicked time to keep them in sync when playing + // This ensures correct time is shown when pausing + setClickedTime(currentTime); + + if (selectedSegmentId !== null) { + setIsPlayingSegment(true); + } + + // While playing, continuously check if we're in a segment or empty space + // to update the tooltip accordingly, regardless of where we started playing + + // Check if we're in any segment at current time + const segmentAtCurrentTime = clipSegments.find( + (seg) => currentTime >= seg.startTime && currentTime <= seg.endTime + ); + + // Update tooltip position based on current time percentage + const newTimePercent = (currentTime / duration) * 100; + if (timelineRef.current) { + const timelineWidth = timelineRef.current.offsetWidth; + const markerX = (newTimePercent / 100) * timelineWidth; + setTooltipPosition({ + x: markerX, + y: timelineRef.current.getBoundingClientRect().top - 10, + }); + } + + // Check for the special "continue past segment" state in sessionStorage + const isContinuingPastSegment = sessionStorage.getItem('continuingPastSegment') === 'true'; + + // If we're in a segment now + if (segmentAtCurrentTime) { + // Get video element reference for boundary checks + const video = videoRef.current; + + // Special check for virtual segments (cutaway playback) + // If we have an active virtual segment (negative ID) and we're in a regular segment now, + // we need to STOP at the start of this segment - that's the boundary of our cutaway + const isPlayingVirtualSegment = activeSegment && activeSegment.id < 0 && isPlayingSegment; + + // If the active segment is different from the current segment and it's not a virtual segment + // and we're not in "continue past boundary" mode, set this segment as the active segment + if ( + activeSegment?.id !== segmentAtCurrentTime.id && + !isPlayingVirtualSegment && + !isContinuingPastSegment && + !continuePastBoundary + ) { + // We've entered a new segment during normal playback + logger.debug( + `Entered a new segment during playback: ${segmentAtCurrentTime.id}, setting as active` + ); + setActiveSegment(segmentAtCurrentTime); + setSelectedSegmentId(segmentAtCurrentTime.id); + setShowEmptySpaceTooltip(false); + // Reset continuation flags to ensure boundary detection works for this new segment + setContinuePastBoundary(false); + sessionStorage.removeItem('continuingPastSegment'); + } + + // If we're playing a virtual segment and enter a real segment, we've reached our boundary + // We should stop playback + if (isPlayingVirtualSegment && video && segmentAtCurrentTime) { + logger.debug( + `CUTAWAY BOUNDARY REACHED: Current position ${formatDetailedTime( + video.currentTime + )} at segment ${segmentAtCurrentTime.id} - STOPPING at boundary ${formatDetailedTime( + segmentAtCurrentTime.startTime + )}` + ); + video.pause(); + // Force exact time position with high precision and multiple attempts + setTimeout(() => { + if (videoRef.current) { + // First seek directly to exact start time, no offset + videoRef.current.currentTime = segmentAtCurrentTime.startTime; + // Update UI immediately to match video position + onSeek(segmentAtCurrentTime.startTime); + // Also update tooltip time displays + setDisplayTime(segmentAtCurrentTime.startTime); + setClickedTime(segmentAtCurrentTime.startTime); + + // Reset continuePastBoundary when reaching a segment boundary + setContinuePastBoundary(false); + + // Update tooltip to show segment tooltip at boundary + setSelectedSegmentId(segmentAtCurrentTime.id); + setShowEmptySpaceTooltip(false); + + // Force multiple adjustments to ensure exact precision + const verifyPosition = () => { + if (videoRef.current) { + // Always force the exact time in every verification + videoRef.current.currentTime = segmentAtCurrentTime.startTime; + + // Make sure we update the UI to reflect the corrected position + onSeek(segmentAtCurrentTime.startTime); + + // Update the displayTime and clickedTime state to match exact position + setDisplayTime(segmentAtCurrentTime.startTime); + setClickedTime(segmentAtCurrentTime.startTime); + + logger.debug( + `Position corrected to exact segment boundary: ${formatDetailedTime( + videoRef.current.currentTime + )} (target: ${formatDetailedTime(segmentAtCurrentTime.startTime)})` + ); + } + }; + + // Apply multiple correction attempts with increasing delays + setTimeout(verifyPosition, 10); // Immediate correction + setTimeout(verifyPosition, 20); // First correction + setTimeout(verifyPosition, 50); // Second correction + setTimeout(verifyPosition, 100); // Third correction + setTimeout(verifyPosition, 200); // Final correction + + // Also add event listeners to ensure position is corrected whenever video state changes + videoRef.current.addEventListener('seeked', verifyPosition); + videoRef.current.addEventListener('canplay', verifyPosition); + videoRef.current.addEventListener('waiting', verifyPosition); + + // Remove these event listeners after a short time + setTimeout(() => { + if (videoRef.current) { + videoRef.current.removeEventListener('seeked', verifyPosition); + videoRef.current.removeEventListener('canplay', verifyPosition); + videoRef.current.removeEventListener('waiting', verifyPosition); + } + }, 300); + } + }, 10); + setIsPlayingSegment(false); + setActiveSegment(null); + return; // Exit early, we've handled this case + } + + // Only update active segment if we're not in "continue past segment" mode + // or if we're in a virtual cutaway segment + const continuingPastSegment = + (activeSegment === null && isPlayingSegment === true) || + isContinuingPastSegment || + isPlayingVirtualSegment; + + if (continuingPastSegment) { + // We're in the special case where we're continuing past a segment boundary + // or playing a cutaway area + // Just update the tooltip, but don't reactivate boundary checking + if (selectedSegmentId !== segmentAtCurrentTime.id || showEmptySpaceTooltip) { + logger.debug( + 'Tooltip updated for segment during continued playback:', + segmentAtCurrentTime.id, + isPlayingVirtualSegment ? '(cutaway playback - keeping virtual segment)' : '' + ); + setSelectedSegmentId(segmentAtCurrentTime.id); + setShowEmptySpaceTooltip(false); + + // If we're in a different segment now, clear the continuation flag + // but only if it's not the same segment we were in before + // AND we're not playing a cutaway area + if ( + !isPlayingVirtualSegment && + sessionStorage.getItem('lastSegmentId') !== segmentAtCurrentTime.id.toString() + ) { + logger.debug('Moved to a different segment - ending continuation mode'); + sessionStorage.removeItem('continuingPastSegment'); + } + } + } else { + // Normal case - update both tooltip and active segment + if (activeSegment?.id !== segmentAtCurrentTime.id || showEmptySpaceTooltip) { + logger.debug('Playback moved into segment:', segmentAtCurrentTime.id); + setSelectedSegmentId(segmentAtCurrentTime.id); + setActiveSegment(segmentAtCurrentTime); + setShowEmptySpaceTooltip(false); + + // Store the current segment ID for comparison later + sessionStorage.setItem('lastSegmentId', segmentAtCurrentTime.id.toString()); + } + } + } + // If we're in empty space now + else { + // Check if we need to change the tooltip (we were in a segment before) + if (activeSegment !== null || !showEmptySpaceTooltip) { + logger.debug('Playback moved to empty space'); + setSelectedSegmentId(null); + setActiveSegment(null); + + // Calculate available space for new segment before showing tooltip + const availableSpace = calculateAvailableSpace(currentTime); + setAvailableSegmentDuration(availableSpace); + + // Show empty space tooltip if there's enough space + if (availableSpace >= 0.5) { + setShowEmptySpaceTooltip(true); + logger.debug('Empty space with available duration:', availableSpace); + } else { + setShowEmptySpaceTooltip(false); + } + } + } + } else if (videoRef.current.paused && isPlayingSegment) { + // When just paused from playing state, update display time to show the actual stopped position + setDisplayTime(currentTime); + setClickedTime(currentTime); + setIsPlayingSegment(false); + + // Log the stopping point + logger.debug('Video paused at:', formatDetailedTime(currentTime)); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTime, isPlayingSegment, activeSegment, selectedSegmentId, clipSegments]); + + // Close zoom dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (isZoomDropdownOpen && !target.closest('.zoom-dropdown-container')) { + setIsZoomDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isZoomDropdownOpen]); + + // Listen for segment updates and trigger auto-save + useEffect(() => { + const handleSegmentUpdate = (event: CustomEvent) => { + const { recordHistory, fromAutoSave } = event.detail; + logger.debug('handleSegmentUpdate called, recordHistory:', recordHistory, 'fromAutoSave:', fromAutoSave); + // Only auto-save when history is recorded and not loading from auto-save + if (recordHistory && !fromAutoSave) { + logger.debug('Calling scheduleAutoSave from handleSegmentUpdate'); + scheduleAutoSave(); + } + }; + + const handleSegmentDragEnd = () => { + // Trigger auto-save when drag operations end + scheduleAutoSave(); + }; + + const handleTrimUpdate = (event: CustomEvent) => { + const { recordHistory } = event.detail; + // Only auto-save when history is recorded (i.e., after trim operations complete) + if (recordHistory) { + scheduleAutoSave(); + } + }; + + document.addEventListener('update-segments', handleSegmentUpdate as EventListener); + document.addEventListener('segment-drag-end', handleSegmentDragEnd); + document.addEventListener('update-trim', handleTrimUpdate as EventListener); + document.addEventListener('delete-segment', scheduleAutoSave); + document.addEventListener('split-segment', scheduleAutoSave); + document.addEventListener('undo-redo-autosave', scheduleAutoSave); + + return () => { + logger.debug('Cleaning up auto-save event listeners...'); + document.removeEventListener('update-segments', handleSegmentUpdate as EventListener); + document.removeEventListener('segment-drag-end', handleSegmentDragEnd); + document.removeEventListener('update-trim', handleTrimUpdate as EventListener); + document.removeEventListener('delete-segment', scheduleAutoSave); + document.removeEventListener('split-segment', scheduleAutoSave); + document.removeEventListener('undo-redo-autosave', scheduleAutoSave); + + // Clear any pending auto-save timer + if (autoSaveTimerRef.current) { + logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current); + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [scheduleAutoSave]); + + // Perform initial auto-save when component mounts with segments + useEffect(() => { + if (clipSegments.length > 0 && !lastAutoSaveTime) { + // Perform initial auto-save after a short delay + setTimeout(() => { + performAutoSave(); + }, 500); + } + }, [lastAutoSaveTime, performAutoSave]); + + // Load saved segments from MEDIA_DATA on component mount + useEffect(() => { + const loadSavedSegments = () => { + // Get savedSegments directly from window.MEDIA_DATA + let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || null; + + try { + if (savedData && savedData.chapters && savedData.chapters.length > 0) { + logger.debug('Found saved segments:', savedData); + + // Convert the saved segments to the format expected by the component + const convertedSegments: Segment[] = savedData.chapters.map((seg: any , index: number) => ({ + id: Date.now() + index, // Generate unique IDs + chapterTitle: seg.chapterTitle || `Chapter ${index + 1}`, + startTime: parseTimeString(seg.startTime), + endTime: parseTimeString(seg.endTime), + })); + + // Dispatch event to update segments + const updateEvent = new CustomEvent('update-segments', { + detail: { + segments: convertedSegments, + recordHistory: false, // Don't record loading saved segments in history + fromAutoSave: true, + }, + }); + document.dispatchEvent(updateEvent); + + // Update the last auto-save time + if (savedData.updated_at) { + const date = new Date(savedData.updated_at); + const formattedTime = date + .toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + .replace(',', ''); + setLastAutoSaveTime(formattedTime); + } + } else { + logger.debug('No saved segments found'); + } + } catch (error) { + console.error('Error loading saved segments:', error); + } + }; + + // Helper function to parse time string "HH:MM:SS.mmm" to seconds + const parseTimeString = (timeStr: string): number => { + const parts = timeStr.split(':'); + if (parts.length !== 3) return 0; + + const hours = parseInt(parts[0]) || 0; + const minutes = parseInt(parts[1]) || 0; + const secondsParts = parts[2].split('.'); + const seconds = parseInt(secondsParts[0]) || 0; + const milliseconds = parseInt(secondsParts[1]) || 0; + + return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000; + }; + + // Load saved segments after a short delay to ensure component is ready + setTimeout(loadSavedSegments, 100); + }, []); // Run only once on mount + + // Global click handler to close tooltips when clicking outside + useEffect(() => { + // Remove the global click handler that closes tooltips + // This keeps the popup always visible, even when clicking outside the timeline + + // Keeping the dependency array to avoid linting errors + return () => {}; + }, [selectedSegmentId, showEmptySpaceTooltip, isPlayingSegment]); + + // Initialize drag handlers for trim handles + useEffect(() => { + const leftHandle = leftHandleRef.current; + const rightHandle = rightHandleRef.current; + const timeline = timelineRef.current; + + if (!leftHandle || !rightHandle || !timeline) return; + + const initDrag = (isLeft: boolean) => (e: MouseEvent) => { + e.preventDefault(); + + const timelineRect = timeline.getBoundingClientRect(); + let isDragging = true; + let finalTime = isLeft ? trimStart : trimEnd; // Track the final time for history recording + + // Use custom events to indicate drag state + const createCustomEvent = (type: string) => { + return new CustomEvent('trim-handle-event', { + detail: { type, isStart: isLeft }, + }); + }; + + // Dispatch start drag event to signal not to record history during drag + document.dispatchEvent(createCustomEvent('drag-start')); + + const onMouseMove = (moveEvent: MouseEvent) => { + if (!isDragging) return; + + const timelineWidth = timelineRect.width; + const position = Math.max(0, Math.min(1, (moveEvent.clientX - timelineRect.left) / timelineWidth)); + const newTime = position * duration; + + // Store position globally for iOS Safari + if (typeof window !== 'undefined') { + window.lastSeekedPosition = newTime; + } + + if (isLeft) { + if (newTime < trimEnd) { + // Don't record in history during drag - this avoids multiple history entries + document.dispatchEvent( + new CustomEvent('update-trim', { + detail: { + time: newTime, + isStart: true, + recordHistory: false, + }, + }) + ); + finalTime = newTime; + } + } else { + if (newTime > trimStart) { + // Don't record in history during drag - this avoids multiple history entries + document.dispatchEvent( + new CustomEvent('update-trim', { + detail: { + time: newTime, + isStart: false, + recordHistory: false, + }, + }) + ); + finalTime = newTime; + } + } + }; + + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + // Now record the final position in history with action type + if (isLeft) { + // Final update with history recording + document.dispatchEvent( + new CustomEvent('update-trim', { + detail: { + time: finalTime, + isStart: true, + recordHistory: true, + action: 'adjust_trim_start', + }, + }) + ); + } else { + document.dispatchEvent( + new CustomEvent('update-trim', { + detail: { + time: finalTime, + isStart: false, + recordHistory: true, + action: 'adjust_trim_end', + }, + }) + ); + } + + // Dispatch end drag event + document.dispatchEvent(createCustomEvent('drag-end')); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + leftHandle.addEventListener('mousedown', initDrag(true)); + rightHandle.addEventListener('mousedown', initDrag(false)); + + return () => { + leftHandle.removeEventListener('mousedown', initDrag(true)); + rightHandle.removeEventListener('mousedown', initDrag(false)); + }; + }, [duration, trimStart, trimEnd, onTrimStartChange, onTrimEndChange]); + + // Render split points + const renderSplitPoints = () => { + return splitPoints.map((point, index) => { + const pointPercent = (point / duration) * 100; + return
; + }); + }; + + // Helper function to calculate available space for a new segment + const calculateAvailableSpace = (startTime: number): number => { + // Always return at least 0.1 seconds to ensure tooltip shows + const MIN_SPACE = 0.1; + + // Determine the amount of available space: + // 1. Check remaining space until the end of video + const remainingDuration = Math.max(0, duration - startTime); + + // 2. Find the next segment (if any) + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + + // Find the next and previous segments + const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime); + const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < startTime); + + // Calculate the actual available space + let availableSpace; + if (nextSegment) { + // Space until next segment + availableSpace = nextSegment.startTime - startTime; + } else { + // Space until end of video + availableSpace = duration - startTime; + } + + // Log the space calculation for debugging + logger.debug('Space calculation:', { + position: formatDetailedTime(startTime), + nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none', + prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none', + availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)), + }); + + // Always return at least MIN_SPACE to ensure tooltip shows + return Math.max(MIN_SPACE, availableSpace); + }; + + // Function to update tooltip based on current time position + const updateTooltipForPosition = (currentPosition: number) => { + if (!timelineRef.current) return; + + // Find if we're in a segment at the current position with a small tolerance + const segmentAtPosition = clipSegments.find((seg) => { + const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; + const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001; + const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001; + return isWithinSegment || isVeryCloseToStart || isVeryCloseToEnd; + }); + + // Find the next and previous segments + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition); + const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition); + + if (segmentAtPosition) { + // We're in or exactly at a segment boundary + setSelectedSegmentId(segmentAtPosition.id); + setShowEmptySpaceTooltip(false); + } else { + // We're in a cutaway area + // Calculate available space for new segment + const availableSpace = calculateAvailableSpace(currentPosition); + setAvailableSegmentDuration(availableSpace); + + // Always show empty space tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(true); + + // Log position info for debugging + logger.debug('Cutaway position:', { + current: formatDetailedTime(currentPosition), + prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none', + nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none', + availableSpace: formatDetailedTime(availableSpace), + }); + } + + // Update tooltip position + const rect = timelineRef.current.getBoundingClientRect(); + const positionPercent = (currentPosition / duration) * 100; + let xPos; + + if (zoomLevel > 1 && scrollContainerRef.current) { + // For zoomed timeline, adjust for scroll position + const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; + xPos = visibleTimelineLeft + rect.width * (positionPercent / 100); + } else { + // For non-zoomed timeline, use simple calculation + xPos = rect.left + rect.width * (positionPercent / 100); + } + + setTooltipPosition({ + x: xPos, + y: rect.top - 10, + }); + }; + + // Handle timeline click to seek and show a tooltip + const handleTimelineClick = (e: React.MouseEvent) => { + // Remove the check that prevents interaction during preview mode + // This allows users to click and jump in the timeline while previewing + + if (!timelineRef.current || !scrollContainerRef.current) return; + + // If on mobile device and video hasn't been initialized, don't handle timeline clicks + if (isIOSUninitialized) { + return; + } + + // Check if video is globally playing before the click + const wasPlaying = videoRef.current && !videoRef.current.paused; + logger.debug('Video was playing before timeline click:', wasPlaying); + + // Reset continuation flag when clicking on timeline - ensures proper boundary detection + setContinuePastBoundary(false); + + const rect = timelineRef.current.getBoundingClientRect(); + + // Account for scroll position when calculating the click position + let position; + if (zoomLevel > 1) { + // When zoomed, we need to account for the scroll position + const scrollLeft = scrollContainerRef.current.scrollLeft; + const totalWidth = timelineRef.current.clientWidth; + position = (e.clientX - rect.left + scrollLeft) / totalWidth; + } else { + // Normal calculation for 1x zoom + position = (e.clientX - rect.left) / rect.width; + } + + const newTime = position * duration; + + // Log the position for debugging + logger.debug( + 'Timeline clicked at:', + formatDetailedTime(newTime), + 'distance from end:', + formatDetailedTime(duration - newTime) + ); + + // Store position globally for iOS Safari (this is critical for first-time visits) + if (typeof window !== 'undefined') { + window.lastSeekedPosition = newTime; + } + + // Seek to the clicked position immediately for all clicks + onSeek(newTime); + + // Always update both clicked time and display time for tooltip actions + setClickedTime(newTime); + setDisplayTime(newTime); + + // Find if we clicked in a segment with a small tolerance for boundaries + const segmentAtClickedTime = clipSegments.find((seg) => { + // Standard check for being inside a segment + const isInside = newTime >= seg.startTime && newTime <= seg.endTime; + // Additional checks for being exactly at the start or end boundary (with small tolerance) + const isAtStart = Math.abs(newTime - seg.startTime) < 0.01; + const isAtEnd = Math.abs(newTime - seg.endTime) < 0.01; + + return isInside || isAtStart || isAtEnd; + }); + + // Handle active segment assignment for boundary checking + if (segmentAtClickedTime) { + setActiveSegment(segmentAtClickedTime); + } + + // Resume playback based on the current mode + if (videoRef.current) { + // Special handling for segments playback mode + if (isPlayingSegments && wasPlaying) { + // Update the current segment index if we clicked into a segment + if (segmentAtClickedTime) { + const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id); + + if (targetSegmentIndex !== -1) { + // Dispatch a custom event to update the current segment index + const updateSegmentIndexEvent = new CustomEvent('update-segment-index', { + detail: { segmentIndex: targetSegmentIndex }, + }); + document.dispatchEvent(updateSegmentIndexEvent); + logger.debug( + `Segments playback mode: updating segment index to ${targetSegmentIndex} for timeline click in segment ${segmentAtClickedTime.id}` + ); + } + } + + logger.debug('Segments playback mode: resuming playback after timeline click'); + videoRef.current + .play() + .then(() => { + setIsPlayingSegment(true); + logger.debug('Resumed segments playback after timeline seeking'); + }) + .catch((err) => { + console.error('Error resuming segments playback:', err); + setIsPlayingSegment(false); + }); + } + // Resume playback if it was playing before (but not during segments playback) + else if (wasPlaying && !isPlayingSegments) { + logger.debug('Resuming playback after timeline click'); + videoRef.current + .play() + .then(() => { + setIsPlayingSegment(true); + logger.debug('Resumed playback after seeking'); + }) + .catch((err) => { + console.error('Error resuming playback:', err); + setIsPlayingSegment(false); + }); + } + } + + // Only process tooltip display if clicked on the timeline background or thumbnails, not on other UI elements + if (e.target === timelineRef.current) { + // Check if there's a segment at the clicked position + if (segmentAtClickedTime) { + setSelectedSegmentId(segmentAtClickedTime.id); + setShowEmptySpaceTooltip(false); + } else { + // We're in a cutaway area - always show tooltip + setSelectedSegmentId(null); + + // Calculate the available space for a new segment + const availableSpace = calculateAvailableSpace(newTime); + setAvailableSegmentDuration(availableSpace); + + // Calculate and set tooltip position correctly for zoomed timeline + let xPos; + if (zoomLevel > 1) { + // For zoomed timeline, calculate the visible position + const visibleTimelineLeft = rect.left - scrollContainerRef.current.scrollLeft; + const clickPosPercent = newTime / duration; + xPos = visibleTimelineLeft + clickPosPercent * rect.width; + } else { + // For 1x zoom, use the client X + xPos = e.clientX; + } + + setTooltipPosition({ + x: xPos, + y: rect.top - 10, // Position tooltip above the timeline + }); + + // Always show the empty space tooltip in cutaway areas + setShowEmptySpaceTooltip(true); + + // Log the cutaway area details + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime); + const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime); + + logger.debug('Clicked in cutaway area:', { + position: formatDetailedTime(newTime), + availableSpace: formatDetailedTime(availableSpace), + prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none', + nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none', + }); + } + } + }; + + // Handle segment resize - works with both mouse and touch events + const handleSegmentResize = (segmentId: number, isLeft: boolean) => (e: React.MouseEvent | React.TouchEvent) => { + // Remove the check that prevents interaction during preview mode + // This allows users to resize segments while previewing + + e.preventDefault(); + e.stopPropagation(); // Prevent triggering parent's events + + if (!timelineRef.current) return; + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineWidth = timelineRect.width; + + // Find the segment that's being resized + const segment = clipSegments.find((seg) => seg.id === segmentId); + if (!segment) return; + + const originalStartTime = segment.startTime; + const originalEndTime = segment.endTime; + + // Store the original segment state to compare after dragging + const segmentBeforeDrag = { ...segment }; + + // Add a visual indicator that we're in resize mode (for mouse devices) + document.body.style.cursor = 'ew-resize'; + + // Add a temporary overlay to help with dragging outside the element + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100vw'; + overlay.style.height = '100vh'; + overlay.style.zIndex = '1000'; + overlay.style.cursor = 'ew-resize'; + document.body.appendChild(overlay); + + // Track dragging state and final positions + let isDragging = true; + let finalStartTime = originalStartTime; + let finalEndTime = originalEndTime; + + // Dispatch an event to signal drag start + document.dispatchEvent( + new CustomEvent('segment-drag-start', { + detail: { segmentId }, + }) + ); + + // Keep the tooltip visible during drag + // Function to handle both mouse and touch movements + const handleDragMove = (clientX: number) => { + if (!isDragging || !timelineRef.current) return; + + const updatedTimelineRect = timelineRef.current.getBoundingClientRect(); + const position = Math.max(0, Math.min(1, (clientX - updatedTimelineRect.left) / updatedTimelineRect.width)); + const newTime = position * duration; + + // Check if the current marker position intersects with where the segment will be + const currentSegmentStart = isLeft ? newTime : originalStartTime; + const currentSegmentEnd = isLeft ? originalEndTime : newTime; + const isMarkerInSegment = currentTime >= currentSegmentStart && currentTime <= currentSegmentEnd; + + // Update tooltip based on marker intersection + if (isMarkerInSegment) { + // Show segment tooltip if marker is inside the segment + setSelectedSegmentId(segmentId); + setShowEmptySpaceTooltip(false); + } else { + // Show cutaway tooltip if marker is outside the segment + setSelectedSegmentId(null); + // Calculate available space for cutaway tooltip + const availableSpace = calculateAvailableSpace(currentTime); + setAvailableSegmentDuration(availableSpace); + setShowEmptySpaceTooltip(true); + } + + // Find neighboring segments (exclude the current one) + const otherSegments = clipSegments.filter((seg) => seg.id !== segmentId); + + // Calculate new start/end times based on drag direction + let newStartTime = originalStartTime; + let newEndTime = originalEndTime; + + if (isLeft) { + // Dragging left handle - adjust start time + newStartTime = Math.min(newTime, originalEndTime - 0.5); + + // Find the closest left neighbor + const leftNeighbors = otherSegments + .filter((seg) => seg.endTime <= originalStartTime) + .sort((a, b) => b.endTime - a.endTime); + + const leftNeighbor = leftNeighbors[0]; + + // Prevent overlapping with left neighbor + if (leftNeighbor && newStartTime < leftNeighbor.endTime) { + newStartTime = leftNeighbor.endTime; + } + + // Snap to the nearest segment with a small threshold + const snapThreshold = 0.3; // seconds + + if (leftNeighbor && Math.abs(newStartTime - leftNeighbor.endTime) < snapThreshold) { + newStartTime = leftNeighbor.endTime; + } + + // Update final value for history recording + finalStartTime = newStartTime; + } else { + // Dragging right handle - adjust end time + newEndTime = Math.max(newTime, originalStartTime + 0.5); + + // Find the closest right neighbor + const rightNeighbors = otherSegments + .filter((seg) => seg.startTime >= originalEndTime) + .sort((a, b) => a.startTime - b.startTime); + + const rightNeighbor = rightNeighbors[0]; + + // Prevent overlapping with right neighbor + if (rightNeighbor && newEndTime > rightNeighbor.startTime) { + newEndTime = rightNeighbor.startTime; + } + + // Snap to the nearest segment with a small threshold + const snapThreshold = 0.3; // seconds + + if (rightNeighbor && Math.abs(newEndTime - rightNeighbor.startTime) < snapThreshold) { + newEndTime = rightNeighbor.startTime; + } + + // Update final value for history recording + finalEndTime = newEndTime; + } + + // Create a new segments array with the updated segment + const updatedSegments = clipSegments.map((seg) => { + if (seg.id === segmentId) { + return { + ...seg, + startTime: newStartTime, + endTime: newEndTime, + }; + } + return seg; + }); + + // Create a custom event to update the segments WITHOUT recording in history during drag + const updateEvent = new CustomEvent('update-segments', { + detail: { + segments: updatedSegments, + recordHistory: false, // Don't record intermediate states + }, + }); + document.dispatchEvent(updateEvent); + + // During dragging, check if the current tooltip needs to be updated based on segment position + if (selectedSegmentId === segmentId && videoRef.current) { + const currentTime = videoRef.current.currentTime; + const segment = updatedSegments.find((seg) => seg.id === segmentId); + + if (segment) { + // Check if playhead position is now outside the segment after dragging + const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime; + + // Log the current position information for debugging + logger.debug( + `During drag - playhead at ${formatDetailedTime(currentTime)} is ${ + isInsideSegment ? 'inside' : 'outside' + } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})` + ); + + if (!isInsideSegment && isPlayingSegment) { + logger.debug('Playhead position is outside segment after dragging - updating tooltip'); + // Stop playback if we were playing and dragged the segment away from playhead + videoRef.current.pause(); + setIsPlayingSegment(false); + setActiveSegment(null); + } + + // Update display time to stay in bounds of the segment + if (currentTime < segment.startTime) { + logger.debug( + `Adjusting display time to segment start: ${formatDetailedTime(segment.startTime)}` + ); + setDisplayTime(segment.startTime); + + // Update UI state to reflect that playback will be from segment start + setClickedTime(segment.startTime); + } else if (currentTime > segment.endTime) { + logger.debug(`Adjusting display time to segment end: ${formatDetailedTime(segment.endTime)}`); + setDisplayTime(segment.endTime); + + // Update UI state to reflect that playback will be from segment end + setClickedTime(segment.endTime); + } + } + } + }; + + // Function to handle the end of dragging (for both mouse and touch) + const handleDragEnd = () => { + if (!isDragging) return; + + isDragging = false; + + // Clean up event listeners for both mouse and touch + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + + // Reset styles + document.body.style.cursor = ''; + if (document.body.contains(overlay)) { + document.body.removeChild(overlay); + } + + // Record the final position in history as a single action + const finalSegments = clipSegments.map((seg) => { + if (seg.id === segmentId) { + return { + ...seg, + startTime: finalStartTime, + endTime: finalEndTime, + }; + } + return seg; + }); + + // Now we can create a history record for the complete drag operation + const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end'; + document.dispatchEvent( + new CustomEvent('update-segments', { + detail: { + segments: finalSegments, + recordHistory: true, + action: actionType, + }, + }) + ); + + // After drag is complete, do a final check to see if playhead is inside the segment + if (selectedSegmentId === segmentId && videoRef.current) { + const currentTime = videoRef.current.currentTime; + const segment = finalSegments.find((seg) => seg.id === segmentId); + + if (segment) { + const isInsideSegment = currentTime >= segment.startTime && currentTime <= segment.endTime; + + logger.debug( + `Drag complete - playhead at ${formatDetailedTime(currentTime)} is ${ + isInsideSegment ? 'inside' : 'outside' + } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})` + ); + + // Check if playhead status changed during drag + const wasInsideSegmentBefore = + currentTime >= segmentBeforeDrag.startTime && currentTime <= segmentBeforeDrag.endTime; + + logger.debug( + `Playhead was ${ + wasInsideSegmentBefore ? 'inside' : 'outside' + } segment before drag, now ${isInsideSegment ? 'inside' : 'outside'}` + ); + + // Update UI elements based on segment position + if (!isInsideSegment) { + // If we were playing and the playhead is now outside the segment, stop playback + if (isPlayingSegment) { + videoRef.current.pause(); + setIsPlayingSegment(false); + setActiveSegment(null); + setContinuePastBoundary(false); + logger.debug('Stopped playback because playhead is outside segment after drag completion'); + } + + // Update display time to be within the segment's bounds + if (currentTime < segment.startTime) { + logger.debug( + `Final adjustment - setting display time to segment start: ${formatDetailedTime( + segment.startTime + )}` + ); + setDisplayTime(segment.startTime); + setClickedTime(segment.startTime); + } else if (currentTime > segment.endTime) { + logger.debug( + `Final adjustment - setting display time to segment end: ${formatDetailedTime( + segment.endTime + )}` + ); + setDisplayTime(segment.endTime); + setClickedTime(segment.endTime); + } + } + // Special case: playhead was outside segment before, but now it's inside - can start playback + else if (!wasInsideSegmentBefore && isInsideSegment) { + logger.debug('Playhead moved INTO segment during drag - can start playback'); + setActiveSegment(segment); + } + // Another special case: playhead was inside segment before, but now is also inside but at a different position + else if ( + wasInsideSegmentBefore && + isInsideSegment && + (segment.startTime !== segmentBeforeDrag.startTime || + segment.endTime !== segmentBeforeDrag.endTime) + ) { + logger.debug( + 'Segment boundaries changed while playhead remained inside - updating activeSegment' + ); + // Update the active segment reference to ensure boundary detection works with new bounds + setActiveSegment(segment); + } + } + } + }; + + // Mouse-specific event handlers + const handleMouseMove = (moveEvent: MouseEvent) => { + handleDragMove(moveEvent.clientX); + }; + + const handleMouseUp = () => { + handleDragEnd(); + }; + + // Touch-specific event handlers + const handleTouchMove = (moveEvent: TouchEvent) => { + if (moveEvent.touches.length > 0) { + moveEvent.preventDefault(); // Prevent scrolling while dragging + handleDragMove(moveEvent.touches[0].clientX); + } + }; + + const handleTouchEnd = () => { + handleDragEnd(); + }; + + // Register event listeners for both mouse and touch + document.addEventListener('mousemove', handleMouseMove, { + passive: false, + }); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove, { + passive: false, + }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + }; + + // Handle segment click to show the tooltip + const handleSegmentClick = (segmentId: number) => (e: React.MouseEvent) => { + // Remove the check that prevents interaction during preview mode + // This allows users to click segments while previewing + + // Don't show tooltip if clicked on handle + if ((e.target as HTMLElement).classList.contains('clip-segment-handle')) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + logger.debug('Segment clicked:', segmentId); + + // Reset continuation flag when selecting a segment - ensures proper boundary detection + setContinuePastBoundary(false); + + // Check if video is currently playing before clicking + const wasPlaying = videoRef.current && !videoRef.current.paused; + logger.debug('seekVideo: Was playing before:', wasPlaying); + + // Set the current segment as selected + setSelectedSegmentId(segmentId); + + // Find the segment in our data + const segment = clipSegments.find((seg) => seg.id === segmentId); + if (!segment) return; + + // Find the segment element in the DOM + const segmentElement = e.currentTarget as HTMLElement; + const segmentRect = segmentElement.getBoundingClientRect(); + + // Calculate relative click position within the segment (0 to 1) + const relativeX = (e.clientX - segmentRect.left) / segmentRect.width; + + // Convert to time based on segment's start and end times + const clickTime = segment.startTime + relativeX * (segment.endTime - segment.startTime); + + // Ensure time is within segment bounds + const boundedTime = Math.max(segment.startTime, Math.min(segment.endTime, clickTime)); + + // Set both clicked time and display time for UI + setClickedTime(boundedTime); + setDisplayTime(boundedTime); + + // Check if the video's current time is inside or outside the segment + // This helps with updating the tooltip correctly after dragging operations + if (videoRef.current) { + const currentVideoTime = videoRef.current.currentTime; + const isPlayheadInsideSegment = + currentVideoTime >= segment.startTime && currentVideoTime <= segment.endTime; + + logger.debug( + `Segment click - playhead at ${formatDetailedTime(currentVideoTime)} is ${ + isPlayheadInsideSegment ? 'inside' : 'outside' + } segment (${formatDetailedTime(segment.startTime)} - ${formatDetailedTime(segment.endTime)})` + ); + + // If playhead is outside the segment, update the display time to segment boundary + if (!isPlayheadInsideSegment) { + // Adjust the display time based on which end is closer to the playhead + if (Math.abs(currentVideoTime - segment.startTime) < Math.abs(currentVideoTime - segment.endTime)) { + // Playhead is closer to segment start + logger.debug( + `Playhead outside segment - adjusting to segment start: ${formatDetailedTime( + segment.startTime + )}` + ); + setDisplayTime(segment.startTime); + // Don't update clickedTime here since we already set it to the clicked position + } else { + // Playhead is closer to segment end + logger.debug( + `Playhead outside segment - adjusting to segment end: ${formatDetailedTime(segment.endTime)}` + ); + setDisplayTime(segment.endTime); + // Don't update clickedTime here since we already set it to the clicked position + } + } + } + + // Seek to this position (this will update the video's current time) + onSeek(boundedTime); + + // Handle playback continuation based on the current mode + if (videoRef.current) { + // Special handling for segments playback mode + if (isPlayingSegments && wasPlaying) { + // Update the current segment index for segments playback mode + const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentId); + + if (targetSegmentIndex !== -1) { + // Dispatch a custom event to update the current segment index + const updateSegmentIndexEvent = new CustomEvent('update-segment-index', { + detail: { segmentIndex: targetSegmentIndex }, + }); + document.dispatchEvent(updateSegmentIndexEvent); + logger.debug( + `Segments playback mode: updating segment index to ${targetSegmentIndex} for segment ${segmentId}` + ); + } + + // In segments playback mode, we want to continue the segments playback from the new position + // The segments playback will naturally handle continuing to the next segments + logger.debug('Segments playback mode: continuing playback from new position'); + videoRef.current + .play() + .then(() => { + setIsPlayingSegment(true); + logger.debug('Continued segments playback after segment click'); + }) + .catch((err) => { + console.error('Error continuing segments playback after segment click:', err); + }); + } + // If video was playing before, ensure it continues playing (but not in segments mode) + else if (wasPlaying && !isPlayingSegments) { + // Set current segment as active segment for boundary checking + setActiveSegment(segment); + // Reset the continuePastBoundary flag when clicking on a segment to ensure boundaries work + setContinuePastBoundary(false); + // Continue playing from the new position + videoRef.current + .play() + .then(() => { + setIsPlayingSegment(true); + logger.debug('Continued preview playback after segment click'); + }) + .catch((err) => { + console.error('Error resuming playback after segment click:', err); + }); + } + } + + // Calculate tooltip position directly above click point + const tooltipX = e.clientX; + const tooltipY = segmentRect.top - 10; + + setTooltipPosition({ + x: tooltipX, + y: tooltipY, + }); + + // Auto-scroll to center the clicked position for zoomed timeline + if (zoomLevel > 1 && timelineRef.current && scrollContainerRef.current) { + const timelineRect = timelineRef.current.getBoundingClientRect(); + const timelineWidth = timelineRef.current.clientWidth; + const containerWidth = scrollContainerRef.current.clientWidth; + + // Calculate pixel position of clicked time + const clickedPosPixel = (boundedTime / duration) * timelineWidth; + + // Center the view on the clicked position + const targetScrollLeft = Math.max(0, clickedPosPixel - containerWidth / 2); + + // Smooth scroll to the clicked point + scrollContainerRef.current.scrollTo({ + left: targetScrollLeft, + behavior: 'smooth', + }); + + // Update tooltip position after scrolling completes + setTimeout(() => { + if (timelineRef.current && scrollContainerRef.current) { + // Calculate new position based on viewport + const updatedRect = timelineRef.current.getBoundingClientRect(); + const timePercent = boundedTime / duration; + const newPosition = + timePercent * timelineWidth - scrollContainerRef.current.scrollLeft + updatedRect.left; + + setTooltipPosition({ + x: newPosition, + y: tooltipY, + }); + } + }, 300); // Wait for smooth scrolling to complete + } + + // We no longer need a local click handler as we have a global one + // that handles closing tooltips when clicking outside + }; + + // Show tooltip for the segment + const setShowTooltip = (show: boolean, segmentId: number, x: number, y: number) => { + setSelectedSegmentId(show ? segmentId : null); + setTooltipPosition({ x, y }); + }; + + // Render the clip segments on the timeline + const renderClipSegments = () => { + // Sort segments by start time to ensure correct chronological order + const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); + return sortedSegments.map((segment, index) => { + const startPercent = (segment.startTime / duration) * 100; + const widthPercent = ((segment.endTime - segment.startTime) / duration) * 100; + + // Generate a solid background color based on segment position + const backgroundColor = generateSolidColor((segment.startTime + segment.endTime) / 2, duration); + + return ( +
+
+
Chapter {index + 1}
+
+ {formatTime(segment.startTime)} - {formatTime(segment.endTime)} +
+
+ Duration: {formatTime(segment.endTime - segment.startTime)} +
+
+ + {/* Resize handles with both mouse and touch support */} + {isPlayingSegments ? null : ( + <> +
{ + e.stopPropagation(); + handleSegmentResize(segment.id, true)(e); + }} + onTouchStart={(e) => { + e.stopPropagation(); + handleSegmentResize(segment.id, true)(e); + }} + >
+
{ + e.stopPropagation(); + handleSegmentResize(segment.id, false)(e); + }} + onTouchStart={(e) => { + e.stopPropagation(); + handleSegmentResize(segment.id, false)(e); + }} + >
+ + )} +
+ ); + }); + }; + + // Add a new useEffect hook to listen for segment deletion events + useEffect(() => { + const handleSegmentDelete = (event: CustomEvent) => { + const { segmentId } = event.detail; + + // Check if this was the last segment before deletion + const remainingSegments = clipSegments.filter((seg) => seg.id !== segmentId); + if (remainingSegments.length === 0) { + // Allow empty state - clear all UI state + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + setActiveSegment(null); + + logger.debug('All segments deleted - entering empty state'); + } else if (selectedSegmentId === segmentId) { + // Handle normal segment deletion + const deletedSegment = clipSegments.find((seg) => seg.id === segmentId); + if (!deletedSegment) return; + + // Calculate available space after deletion + const availableSpace = calculateAvailableSpace(currentTime); + + // Update UI to show cutaway tooltip + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(true); + setAvailableSegmentDuration(availableSpace); + + // Calculate tooltip position + if (timelineRef.current) { + const rect = timelineRef.current.getBoundingClientRect(); + const posPercent = (currentTime / duration) * 100; + const xPosition = rect.left + rect.width * (posPercent / 100); + + setTooltipPosition({ + x: xPosition, + y: rect.top - 10, + }); + + logger.debug('Segment deleted, showing cutaway tooltip:', { + position: formatDetailedTime(currentTime), + availableSpace: formatDetailedTime(availableSpace), + }); + } + } + }; + + // Add event listener for the custom delete-segment event + document.addEventListener('delete-segment', handleSegmentDelete as EventListener); + + // Clean up event listener on component unmount + return () => { + document.removeEventListener('delete-segment', handleSegmentDelete as EventListener); + }; + }, [selectedSegmentId, clipSegments, currentTime, duration, timelineRef]); + + // Add an effect to synchronize tooltip play state with video play state + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePlay = () => { + // Simple play handler - just update UI state, no boundary checking + setIsPlaying(true); + setIsPlayingSegment(true); + logger.debug('Continuous playback started from TimelineControls'); + }; + + const handlePause = () => { + logger.debug('Video paused from external control'); + setIsPlaying(false); + setIsPlayingSegment(false); + }; + + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + + return () => { + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + }; + }, []); + + // Handle mouse movement over timeline to remember position + const handleTimelineMouseMove = (e: React.MouseEvent) => { + if (!timelineRef.current) return; + + const rect = timelineRef.current.getBoundingClientRect(); + const position = (e.clientX - rect.left) / rect.width; + const time = position * duration; + + // Ensure time is within bounds + const boundedTime = Math.max(0, Math.min(duration, time)); + + // Store position globally for iOS Safari + if (typeof window !== 'undefined') { + window.lastSeekedPosition = boundedTime; + } + }; + + // Add the dragging state and handlers to the component + + // Inside the TimelineControls component, add these new state variables + const [isDragging, setIsDragging] = useState(false); + // Add a dragging ref to track state without relying on React's state updates + const isDraggingRef = useRef(false); + + // Add drag handlers to enable dragging the timeline marker + const startDrag = (e: React.MouseEvent | React.TouchEvent) => { + // If on mobile device and video hasn't been initialized, don't allow dragging + if (isIOSUninitialized) { + return; + } + + e.stopPropagation(); // Don't trigger the timeline click + e.preventDefault(); // Prevent text selection during drag + + setIsDragging(true); + isDraggingRef.current = true; // Use ref for immediate value access + + // Show tooltip immediately when starting to drag + updateTooltipForPosition(currentTime); + + // Handle mouse events + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!timelineRef.current || !scrollContainerRef.current) return; + + // Calculate the position based on mouse or touch coordinates + const rect = timelineRef.current.getBoundingClientRect(); + let position; + + if (zoomLevel > 1) { + // When zoomed, account for scroll position + const scrollLeft = scrollContainerRef.current.scrollLeft; + const totalWidth = timelineRef.current.clientWidth; + position = (moveEvent.clientX - rect.left + scrollLeft) / totalWidth; + } else { + // Normal calculation for 1x zoom + position = (moveEvent.clientX - rect.left) / rect.width; + } + + // Constrain position between 0 and 1 + position = Math.max(0, Math.min(1, position)); + + // Convert to time and seek + const newTime = position * duration; + + // Update both clicked time and display time + setClickedTime(newTime); + setDisplayTime(newTime); + + // Update tooltip state based on new position + updateTooltipForPosition(newTime); + + // Store position globally for iOS Safari + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = newTime; + } + + // Seek to the new position + onSeek(newTime); + }; + + // Handle mouse up to stop dragging + const handleMouseUp = () => { + setIsDragging(false); + isDraggingRef.current = false; // Update ref immediately + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + // Add event listeners to track movement and release + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // Handle touch events for mobile devices + const startTouchDrag = (e: React.TouchEvent) => { + // If on mobile device and video hasn't been initialized, don't allow dragging + if (isIOSUninitialized) { + return; + } + + e.stopPropagation(); // Don't trigger the timeline click + e.preventDefault(); // Prevent text selection during drag + + setIsDragging(true); + isDraggingRef.current = true; // Use ref for immediate value access + + // Show tooltip immediately when starting to drag + updateTooltipForPosition(currentTime); + + // Handle touch move events + const handleTouchMove = (moveEvent: TouchEvent) => { + if (!timelineRef.current || !scrollContainerRef.current || !moveEvent.touches[0]) return; + + // Calculate the position based on touch coordinates + const rect = timelineRef.current.getBoundingClientRect(); + let position; + + if (zoomLevel > 1) { + // When zoomed, account for scroll position + const scrollLeft = scrollContainerRef.current.scrollLeft; + const totalWidth = timelineRef.current.clientWidth; + position = (moveEvent.touches[0].clientX - rect.left + scrollLeft) / totalWidth; + } else { + // Normal calculation for 1x zoom + position = (moveEvent.touches[0].clientX - rect.left) / rect.width; + } + + // Constrain position between 0 and 1 + position = Math.max(0, Math.min(1, position)); + + // Convert to time and seek + const newTime = position * duration; + + // Update both clicked time and display time + setClickedTime(newTime); + setDisplayTime(newTime); + + // Update tooltip state based on new position + updateTooltipForPosition(newTime); + + // Store position globally for mobile browsers + if (typeof window !== 'undefined') { + (window as any).lastSeekedPosition = newTime; + } + + // Seek to the new position + onSeek(newTime); + }; + + // Handle touch end to stop dragging + const handleTouchEnd = () => { + setIsDragging(false); + isDraggingRef.current = false; // Update ref immediately + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchEnd); + }; + + // Add event listeners to track movement and release + document.addEventListener('touchmove', handleTouchMove, { + passive: false, + }); + document.addEventListener('touchend', handleTouchEnd); + document.addEventListener('touchcancel', handleTouchEnd); + }; + + // Add a useEffect to log the redirect URL whenever it changes + useEffect(() => { + if (redirectUrl) { + logger.debug('Redirect URL updated:', { + redirectUrl, + saveType, + isSuccessModalOpen: showSuccessModal, + }); + } + }, [redirectUrl, saveType, showSuccessModal]); + + + // Note: Removed the conflicting redirect effect - redirect is now handled by cancelRedirect function + + return ( +
+ {/* Current Timecode with Milliseconds */} +
+
+ Timeline +
+
+ Total Chapters:{' '} + + {formatDetailedTime( + clipSegments.reduce((sum, segment) => sum + (segment.endTime - segment.startTime), 0) + )} + +
+
+ + {/* Timeline Container with Scrollable Wrapper */} +
1 ? 'auto' : 'hidden', + }} + > +
+ {/* Current Position Marker */} +
+ {/* Top circle for popup toggle */} +
{ + // Prevent event propagation to avoid triggering the timeline container click + e.stopPropagation(); + + // For ensuring accurate segment detection, refresh clipSegments first + // This helps when clicking right after creating a new segment + const refreshedSegmentAtCurrentTime = clipSegments.find( + (seg) => currentTime >= seg.startTime && currentTime <= seg.endTime + ); + + // Toggle tooltip visibility with a single click + if (selectedSegmentId || showEmptySpaceTooltip) { + // When tooltip is open and - icon is clicked, simply close the tooltips + logger.debug('Closing tooltip'); + setSelectedSegmentId(null); + setShowEmptySpaceTooltip(false); + // Don't reopen the tooltip - just leave it closed + return; + } else { + // Use our improved tooltip position logic + updateTooltipForPosition(currentTime); + logger.debug('Opening tooltip at:', formatDetailedTime(currentTime)); + } + }} + > + + {selectedSegmentId || showEmptySpaceTooltip ? '-' : '+'} + +
+ + {/* Bottom circle for dragging */} + {isPlayingSegments ? null : ( +
+ +
+ )} +
+ + {/* Trim Line Markers - hidden when segments exist */} + {clipSegments.length === 0 && ( + <> +
+
+
+
+
+
+ + )} + + {/* Clip Segments */} + {renderClipSegments()} + + {/* Split Points */} + {renderSplitPoints()} + + {/* Segment Tooltip */} + {selectedSegmentId !== null && ( +
{ + if (isPlayingSegments) { + e.stopPropagation(); + e.preventDefault(); + } + }} + > + {/* Chapter Editor for this segment */} + {selectedSegmentId && ( +
+