Compare commits

...

12 Commits
v6.6.0 ... main

Author SHA1 Message Date
Yiannis Christodoulou
2a0cb977f2
Video.js fixes and improvements after major upgrade (#1413) 2025-10-27 11:53:35 +02:00
Yiannis Christodoulou
a5e6e7b9ca
feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements 2025-10-20 15:30:00 +03:00
Markos Gogoulos
b39072c8ae feat: disapprove user 2025-10-09 17:29:29 +03:00
Markos Gogoulos
f4ab60e894 fix: extend gitignore 2025-09-26 11:33:44 +03:00
Markos Gogoulos
8656b40c5b fix: url 2025-09-25 08:51:26 +03:00
Markos Gogoulos
553a25a86f
feat: pass extra css (#1392) 2025-09-24 19:01:06 +03:00
Markos Gogoulos
1c1af489f1
feat: allow portal logo override (#1391) 2025-09-24 16:26:45 +03:00
Markos Gogoulos
c4c5ecf06a update version 2025-09-21 16:18:31 +03:00
Markos Gogoulos
725cc71960 fix migration 2025-09-21 16:17:18 +03:00
Markos Gogoulos
0c1c5bbb09 add new migration 2025-09-21 16:15:46 +03:00
Markos Gogoulos
56182f0a6d feat: allow customizable about page 2025-09-21 15:47:04 +03:00
Markos Gogoulos
208f0b338b
feat: update versions for python packages, add Pages functionality (#1386)
This PR updates Django core version and also brings html pages support (that admins can create)
2025-09-21 15:38:43 +03:00
568 changed files with 64153 additions and 238759 deletions

6
.gitignore vendored
View File

@ -5,6 +5,7 @@ media_files/original/
media_files/hls/
media_files/chunks/
media_files/uploads/
media_files/tinymce_media/
postgres_data/
celerybeat-schedule
logs/
@ -29,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4
.DS_Store
static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4
frontend-tools/video-js/public/videos/sample-video-white.mp4
frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3

View File

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

21
.prettierrc Normal file
View File

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

View File

@ -34,6 +34,7 @@ INSTALLED_APPS = [
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
"corsheaders",
"tinymce",
]
MIDDLEWARE = [

View File

@ -105,6 +105,23 @@ USE_L10N = True
USE_TZ = True
SITE_ID = 1
# these are the portal logos (dark and light)
# set new paths for svg or png if you want to override
# svg has priority over png, so if you want to use
# custom pngs and not svgs, remove the lines with svgs
# or set as empty strings
# example:
# PORTAL_LOGO_DARK_SVG = ""
# PORTAL_LOGO_LIGHT_SVG = ""
# place the files on static/images folder
PORTAL_LOGO_DARK_SVG = "/static/images/logo_dark.svg"
PORTAL_LOGO_DARK_PNG = "/static/images/logo_dark.png"
PORTAL_LOGO_LIGHT_SVG = "/static/images/logo_light.svg"
PORTAL_LOGO_LIGHT_PNG = "/static/images/logo_dark.png"
# paths to extra css files to be included, eg "/static/css/custom.css"
# place css inside static/css folder
EXTRA_CSS_PATHS = []
# protection agains anonymous users
# per ip address limit, for actions as like/dislike/report
TIME_TO_ACTION_ANONYMOUS = 10 * 60
@ -289,6 +306,7 @@ INSTALLED_APPS = [
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
"tinymce",
]
MIDDLEWARE = [
@ -472,6 +490,46 @@ LANGUAGES = [
LANGUAGE_CODE = 'en' # default language
TINYMCE_DEFAULT_CONFIG = {
"theme": "silver",
"height": 500,
"resize": "both",
"menubar": "file edit view insert format tools table help",
"menu": {
"format": {
"title": "Format",
"items": "blocks | bold italic underline strikethrough superscript subscript code | " "fontfamily fontsize align lineheight | " "forecolor backcolor removeformat",
},
},
"plugins": "advlist,autolink,autosave,lists,link,image,charmap,print,preview,anchor,"
"searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,directionality,"
"code,help,wordcount,emoticons,file,image,media",
"toolbar": "undo redo | code preview | blocks | "
"bold italic | alignleft aligncenter "
"alignright alignjustify ltr rtl | bullist numlist outdent indent | "
"removeformat | restoredraft help | image media",
"branding": False, # remove branding
"promotion": False, # remove promotion
"body_class": "page-main-inner custom-page-wrapper", # class of the body element in tinymce
"block_formats": "Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3;",
"formats": { # customize h2 to always have emphasis-large class
"h2": {"block": "h2", "classes": "emphasis-large"},
},
"font_size_formats": "16px 18px 24px 32px",
"images_upload_url": "/tinymce/upload/",
"images_upload_handler": "tinymce.views.upload_image",
"automatic_uploads": True,
"file_picker_types": "image",
"paste_data_images": True,
"paste_as_text": False,
"paste_enable_default_filters": True,
"paste_word_valid_elements": "b,strong,i,em,h1,h2,h3,h4,h5,h6,p,br,a,ul,ol,li",
"paste_retain_style_properties": "all",
"paste_remove_styles": False,
"paste_merge_formats": True,
"sandbox_iframes": False,
}
SPRITE_NUM_SECS = 10
# number of seconds for sprite image.
# If you plan to change this, you must also follow the instructions on admins_docs.md
@ -519,7 +577,12 @@ FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.26
ALLOWED_MEDIA_UPLOAD_TYPES = ["video", "audio", "image", "pdf"]
# transcription options
# the full docker image needs to be used in order to be able to use transcription
# the mediacms-full docker image needs to be used in order to be able to use transcription
# if you are using the mediacms-full image, change USE_WHISPER_TRANSCRIBE to True
USE_WHISPER_TRANSCRIBE = False
# by default all users can request a video to be transcribed. If you want to
# allow only editors, set this to False
USER_CAN_TRANSCRIBE_VIDEO = True
# Whisper transcribe options - https://github.com/openai/whisper

View File

@ -30,6 +30,7 @@ urlpatterns = [
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path("tinymce/", include("tinymce.urls")),
]
admin.site.site_header = "MediaCMS Admin"

View File

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

View File

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

View File

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

View File

@ -33,55 +33,35 @@ services:
volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- npm_global:/home/node/.npm-global
- npm_cache:/home/node/.npm
working_dir: /home/mediacms.io/mediacms/frontend/
command: >
bash -c "
echo 'Setting up npm global directory...' &&
mkdir -p /home/node/.npm-global &&
chown -R node:node /home/node/.npm-global &&
echo 'Setting up permissions...' &&
chown -R node:node /home/mediacms.io/mediacms/frontend &&
echo 'Cleaning up node_modules...' &&
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
echo 'Switching to node user...' &&
su node -c '
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
echo \"Setting up frontend...\" &&
rm -f package-lock.json &&
rm -f packages/player/package-lock.json &&
rm -f packages/scripts/package-lock.json &&
echo \"Installing dependencies...\" &&
npm install --legacy-peer-deps &&
echo \"Setting up workspaces...\" &&
npm install -g npm@latest &&
echo 'Checking dependencies...' &&
if [ ! -f node_modules/.install-complete ]; then
echo 'First-time setup or dependencies changed, installing...' &&
npm install --legacy-peer-deps --cache /home/node/.npm &&
cd packages/scripts &&
npm install --legacy-peer-deps &&
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
npm install tslib@2.6.2 --save --legacy-peer-deps &&
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
npm install --legacy-peer-deps &&
npm install --legacy-peer-deps --cache /home/node/.npm &&
npm run build &&
cd ../.. &&
cd packages/player &&
npm install --legacy-peer-deps &&
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:

View File

@ -129,6 +129,7 @@ or if you have set the ADMIN_PASSWORD variable on docker-compose file you have u
* either use the docker-compose.full.yaml, so in this case run `docker-compose -f docker-compose.yaml -f docker-compose.full.yaml up`
* or edit the docker-compose.yaml file and set the image for the celery_worker service as mediacms/mediacms:full instead of mediacms/mediacms:latest
Plus set variable `USE_WHISPER_TRANSCRIBE = True` in the settings.py file
### Update
@ -239,7 +240,12 @@ Docker Compose installation: edit `deploy/docker/local_settings.py`, make a chan
### 5.1 Change portal logo
Set a new svg file for the white theme (`static/images/logo_dark.svg`) or the dark theme (`static/images/logo_light.svg`)
Find the default svg files for the white theme on `static/images/logo_dark.svg` and for the dark theme on `static/images/logo_light.svg`
You can specify new svg paths to override by editing the `PORTAL_LOGO_DARK_SVG` and `PORTAL_LOGO_LIGHT_SVG` variables in `settings.py`.
You can also use custom pngs, by setting the variables `PORTAL_LOGO_DARK_PNG` and `PORTAL_LOGO_LIGHT_PNG` in `settings.py`. The svg files have priority over png files, so if both are set, svg files will be used.
In any case, make sure the files are placed on the static/images folder.
### 5.2 Set global portal title
@ -1027,7 +1033,7 @@ When the whisper transcribe task is triggered for a media file, MediaCMS runs th
### Configuration
Transcription functionality is available only for the Docker installation. To enable this feature, you must either use the `docker-compose.full.yaml` file, as it contains an image with the necessary requirements, or you can also set that celery_worker service is usine mediacms:full image instead of mediacms:latest.
Transcription functionality is available only for the Docker installation. To enable this feature, you must either use the `docker-compose.full.yaml` file, as it contains an image with the necessary requirements, or you can also set that celery_worker service is usine mediacms:full image instead of mediacms:latest. Then you also have to set the setting: `USE_WHISPER_TRANSCRIBE = True` in your local_settings.py file.
By default, all users have the ability to send a request for a video to be transcribed, as well as transcribed and translated to English. If you wish to change this behavior, you can edit the `settings.py` file and set `USER_CAN_TRANSCRIBE_VIDEO=False`.

View File

@ -3,6 +3,7 @@ from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import transaction
from tinymce.widgets import TinyMCE
from rbac.models import RBACGroup
@ -13,8 +14,10 @@ from .models import (
Encoding,
Language,
Media,
Page,
Subtitle,
Tag,
TinyMCEMedia,
TranscriptionRequest,
VideoTrimRequest,
)
@ -224,11 +227,39 @@ class TranscriptionRequestAdmin(admin.ModelAdmin):
pass
class PageAdminForm(forms.ModelForm):
description = forms.CharField(widget=TinyMCE())
def clean_description(self):
content = self.cleaned_data['description']
# Add sandbox attribute to all iframes
content = content.replace('<iframe ', '<iframe sandbox="allow-scripts allow-same-origin allow-presentation" ')
return content
class Meta:
model = Page
fields = "__all__"
class PageAdmin(admin.ModelAdmin):
form = PageAdminForm
@admin.register(TinyMCEMedia)
class TinyMCEMediaAdmin(admin.ModelAdmin):
list_display = ['original_filename', 'file_type', 'uploaded_at', 'user']
list_filter = ['file_type', 'uploaded_at']
search_fields = ['original_filename']
readonly_fields = ['uploaded_at']
date_hierarchy = 'uploaded_at'
admin.site.register(EncodeProfile, EncodeProfileAdmin)
admin.site.register(Comment, CommentAdmin)
admin.site.register(Media, MediaAdmin)
admin.site.register(Encoding, EncodingAdmin)
admin.site.register(Category, CategoryAdmin)
admin.site.register(Page, PageAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Subtitle, SubtitleAdmin)
admin.site.register(Language, LanguageAdmin)

View File

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

View File

@ -429,6 +429,9 @@ def user_allowed_to_upload(request):
def can_transcribe_video(user):
"""Checks if a user can transcribe a video."""
if not getattr(settings, 'USE_WHISPER_TRANSCRIBE', False):
return False
if is_mediacms_editor(user):
return True
if getattr(settings, 'USER_CAN_TRANSCRIBE_VIDEO', False):
@ -601,7 +604,7 @@ def handle_video_chapters(media, chapters):
else:
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return media.chapter_data
return {'chapters': media.chapter_data}
def change_media_owner(media_id, new_user):

View File

@ -0,0 +1,42 @@
# Generated by Django 5.2.6 on 2025-09-21 11:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0012_media_allow_whisper_transcribe_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Page',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=200, unique=True)),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('add_date', models.DateTimeField(auto_now_add=True)),
('edit_date', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='TinyMCEMedia',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='tinymce_media/')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('file_type', models.CharField(choices=[('image', 'Image'), ('media', 'Media')], max_length=10)),
('original_filename', models.CharField(max_length=255)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'TinyMCE Media',
'verbose_name_plural': 'TinyMCE Media',
'ordering': ['-uploaded_at'],
},
),
]

View File

@ -4,6 +4,7 @@ from .comment import Comment # noqa: F401
from .encoding import EncodeProfile, Encoding # noqa: F401
from .license import License # noqa: F401
from .media import Media, MediaPermission # noqa: F401
from .page import Page, TinyMCEMedia # noqa: F401
from .playlist import Playlist, PlaylistMedia # noqa: F401
from .rating import Rating, RatingCategory # noqa: F401
from .subtitle import Language, Subtitle, TranscriptionRequest # noqa: F401

View File

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

42
files/models/page.py Normal file
View File

@ -0,0 +1,42 @@
from django.db import models
from django.urls import reverse
class Page(models.Model):
slug = models.SlugField(max_length=200, unique=True)
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
add_date = models.DateTimeField(auto_now_add=True)
edit_date = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("get_page", args=[str(self.slug)])
class TinyMCEMedia(models.Model):
file = models.FileField(upload_to='tinymce_media/')
uploaded_at = models.DateTimeField(auto_now_add=True)
file_type = models.CharField(
max_length=10,
choices=(
('image', 'Image'),
('media', 'Media'),
),
)
original_filename = models.CharField(max_length=255)
user = models.ForeignKey("users.User", on_delete=models.CASCADE, null=True, blank=True)
class Meta:
verbose_name = 'TinyMCE Media'
verbose_name_plural = 'TinyMCE Media'
ordering = ['-uploaded_at']
def __str__(self):
return f"{self.original_filename} ({self.file_type})"
@property
def url(self):
return self.file.url

View File

@ -12,40 +12,19 @@ class VideoChapterData(models.Model):
class Meta:
unique_together = ['media']
def save(self, *args, **kwargs):
from .. import tasks
is_new = self.pk is None
if is_new or (not is_new and self._check_data_changed()):
super().save(*args, **kwargs)
tasks.produce_video_chapters.delay(self.pk)
else:
super().save(*args, **kwargs)
def _check_data_changed(self):
if self.pk:
old_instance = VideoChapterData.objects.get(pk=self.pk)
return old_instance.data != self.data
return False
@property
def chapter_data(self):
# ensure response is consistent
data = []
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

View File

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

20
files/tinymce_handlers.py Normal file
View File

@ -0,0 +1,20 @@
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from .models import TinyMCEMedia
@csrf_exempt
def upload_image(request):
if not (request.user.is_authenticated and request.user.is_active and request.user.is_staff and request.user.is_superuser):
return JsonResponse({'error': 'Admin access required'}, status=403)
if request.method == "POST":
file_obj = request.FILES.get('file')
if file_obj:
# Create a new TinyMCEMedia instance for the image
media = TinyMCEMedia(file=file_obj, file_type='image', original_filename=file_obj.name, user=request.user if request.user.is_authenticated else None)
media.save()
return JsonResponse({'location': media.url})
return JsonResponse({'error': 'Invalid request'}, status=400)

View File

@ -4,7 +4,7 @@ from django.conf.urls import include
from django.conf.urls.static import static
from django.urls import path, re_path
from . import management_views, views
from . import management_views, tinymce_handlers, views
from .feeds import IndexRSSFeed, SearchRSSFeed
friendly_token = r"(?P<friendly_token>[\w\-_]*)"
@ -108,10 +108,14 @@ urlpatterns = [
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
re_path(r"^manage/media$", views.manage_media, name="manage_media"),
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
# Media uploads in ADMIN created pages
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.USERS_NEEDS_TO_BE_APPROVED:
urlpatterns.append(re_path(r"^approval_required", views.approval_required, name="approval_required"))
urlpatterns.append(re_path(r"^approval_required/", views.approval_required, name="approval_required"))
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))

View File

@ -20,6 +20,7 @@ from .pages import edit_subtitle # noqa: F401
from .pages import edit_video # noqa: F401
from .pages import embed_media # noqa: F401
from .pages import featured_media # noqa: F401
from .pages import get_page # noqa: F401
from .pages import history # noqa: F401
from .pages import index # noqa: F401
from .pages import latest_media # noqa: F401

View File

@ -31,10 +31,20 @@ from ..methods import (
is_media_allowed_type,
is_mediacms_editor,
)
from ..models import Category, Media, Playlist, Subtitle, Tag, VideoTrimRequest
from ..models import Category, Media, Page, Playlist, Subtitle, Tag, VideoTrimRequest
from ..tasks import save_user_action, video_trim_task
def get_page(request, slug):
context = {}
page = Page.objects.filter(slug=slug).first()
if page:
context["page"] = page
else:
return render(request, "404.html", context)
return render(request, "cms/page.html", context)
@login_required
def record_screen(request):
"""Record screen view"""
@ -50,6 +60,12 @@ def record_screen(request):
def about(request):
"""About view"""
page = Page.objects.filter(slug="about").first()
if page:
context = {}
context["page"] = page
return render(request, "cms/page.html", context)
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
@ -228,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("/")
@ -242,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)
@ -342,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("/")
@ -355,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},
)
@ -410,7 +429,7 @@ def edit_video(request):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not media.media_type == "video":
if media.media_type not in ["video", "audio"]:
messages.add_message(request, messages.INFO, "Media is not video")
return HttpResponseRedirect(media.get_absolute_url())

View File

@ -0,0 +1,15 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
yt.readme.md
client/public/videos/sample-video.mp4
client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-37s.mp4
videos/sample-video-37s.mp4
client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-1.mp4
client/public/videos/sample-video-10m.mp4
client/public/videos/sample-video-10s.mp4

View File

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

View File

@ -0,0 +1,255 @@
# MediaCMS Chapters Editor
A modern browser-based chapter editing tool built with React and TypeScript that integrates with MediaCMS. The Chapters Editor allows users to create, manage, and edit video chapters with precise timing controls and an intuitive timeline interface.
## Features
- 📑 Create and manage video chapters with custom titles
- ⏱️ Precise timestamp controls for chapter start and end points
- ✂️ Split chapters and reorganize content
- 👁️ Preview chapters with jump-to navigation
- 🔄 Undo/redo support for all editing operations
- 🏷️ Chapter metadata editing (titles, descriptions)
- 💾 Save chapter data directly to MediaCMS
- 🎯 Timeline-based chapter visualization
- 📱 Responsive design for desktop and mobile
## Use Cases
- **Educational Content**: Add chapters to lectures and tutorials for better navigation
- **Entertainment**: Create chapters for movies, shows, or long-form content
- **Documentation**: Organize training videos and documentation with logical sections
- **Accessibility**: Improve content accessibility with structured navigation
## Tech Stack
- React 18
- TypeScript
- Vite
## Installation
### Prerequisites
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
- Yarn or npm package manager
### Setup
```bash
# Navigate to the Chapters Editor directory
cd frontend-tools/chapters-editor
# Install dependencies with Yarn
yarn install
# Or with npm
npm install
```
## Development
The Chapters Editor can be run in two modes:
### Standalone Development Mode
This starts a local development server with hot reloading:
```bash
# Start the development server with Yarn
yarn dev
# Or with npm
npm run dev
```
### Frontend-only Development Mode
If you want to work only on the frontend with MediaCMS backend:
```bash
# Start frontend-only development with Yarn
yarn dev:frontend
# Or with npm
npm run dev:frontend
```
## Building
### For MediaCMS Integration
To build the Chapters Editor for integration with MediaCMS:
```bash
# Build for Django integration with Yarn
yarn build:django
# Or with npm
npm run build:django
```
This will compile the editor and place the output in the MediaCMS static directory.
### Standalone Build
To build the editor as a standalone application:
```bash
# Build for production with Yarn
yarn build
# Or with npm
npm run build
```
## Deployment
To deploy the Chapters Editor, you can use the build and deploy script (recommended):
```bash
# Run the deployment script
sh deploy/scripts/build_and_deploy.sh
```
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
You can also deploy manually after building:
```bash
# With Yarn
yarn deploy
# Or with npm
npm run deploy
```
## Project Structure
- `/client` - Frontend React application
- `/src` - Source code
- `/components` - React components for chapter editing
- `/hooks` - Custom React hooks for chapter management
- `/lib` - Utility functions and helpers
- `/services` - API services for MediaCMS integration
- `/styles` - CSS and style definitions
- `/shared` - Shared TypeScript types and utilities
## API Integration
The Chapters Editor interfaces with MediaCMS through a set of API endpoints for:
- Retrieving video metadata and existing chapters
- Saving chapter data (timestamps, titles, descriptions)
- Validating chapter structure and timing
- Integration with MediaCMS user permissions
### Chapter Data Format
Chapters are stored in the following format:
```json
{
"chapters": [
{
"id": "chapter-1",
"title": "Introduction",
"startTime": 0,
"endTime": 120,
"description": "Opening remarks and overview"
},
{
"id": "chapter-2",
"title": "Main Content",
"startTime": 120,
"endTime": 600,
"description": "Core educational material"
}
]
}
```
## Code Formatting
To automatically format all source files using [Prettier](https://prettier.io):
```bash
# Format all code in the src directory
npx prettier --write client/src/
# Or format specific file types
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
```
You can also add this as a script in `package.json`:
```json
"scripts": {
"format": "prettier --write client/src/"
}
```
Then run:
```bash
yarn format
# or
npm run format
```
## Testing
Run the test suite to ensure Chapters Editor functionality:
```bash
# Run tests with Yarn
yarn test
# Or with npm
npm test
# Run tests in watch mode
yarn test:watch
npm run test:watch
```
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/chapter-enhancement`
3. Make your changes and add tests
4. Run the formatter: `yarn format`
5. Run tests: `yarn test`
6. Commit your changes: `git commit -m "Add chapter enhancement"`
7. Push to the branch: `git push origin feature/chapter-enhancement`
8. Submit a pull request
## Troubleshooting
### Common Issues
**Chapter timestamps not saving**: Ensure the MediaCMS backend API is accessible and user has proper permissions.
**Timeline not displaying correctly**: Check browser console for JavaScript errors and ensure video file is properly loaded.
**Performance issues with long videos**: The editor is optimized for videos up to 2 hours. For longer content, consider splitting into multiple files.
### Debug Mode
Enable debug mode for detailed logging:
```bash
# Start with debug logging
DEBUG=true yarn dev
```
## Browser Support
- Chrome/Chromium 90+
- Firefox 88+
- Safari 14+
- Edge 90+
## License
This project is licensed under the same license as MediaCMS. See the main MediaCMS repository for license details.

View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>Chapters Editor</title>
<!-- Add meta tag to help iOS devices render as desktop -->
<script>
// Try to detect iOS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
if (isIOS) {
// Replace viewport meta tag with one optimized for desktop view
const viewportMeta = document.querySelector('meta[name="viewport"]');
if (viewportMeta) {
viewportMeta.setAttribute(
'content',
'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
);
}
// Add a class to the HTML element for iOS-specific styles
document.documentElement.classList.add('ios-device');
}
</script>
</head>
<body>
<div id="chapters-editor-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

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

View File

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

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 611 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<title/>
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
<g transform="translate(2, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<title/>
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
<g transform="translate(2, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(30, 0) scale(-1, 1)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(28, 0) scale(-1, 1)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1,93 @@
import { formatTime, formatLongTime } from '@/lib/timeUtils';
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
chapterTitle: string;
startTime: number;
endTime: number;
}
interface ClipSegmentsProps {
segments: Segment[];
selectedSegmentId?: number | null;
}
const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Handle delete segment click
const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event
const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId },
});
document.dispatchEvent(deleteEvent);
};
// Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8
// This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
// Get selected segment
const selectedSegment = sortedSegments.find((seg) => seg.id === selectedSegmentId);
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Chapters</h3>
{sortedSegments.map((segment, index) => (
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)} ${selectedSegmentId === segment.id ? 'selected' : ''}`}
>
<div className="segment-content">
<div className="segment-info">
<div className="segment-title">
{segment.chapterTitle ? (
<span className="chapter-title">{segment.chapterTitle}</span>
) : (
<span className="default-title">Chapter {index + 1}</span>
)}
</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div>
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No chapters created yet. Use the split button to create chapter segments.
</div>
)}
</div>
);
};
export default ClipSegments;

View File

@ -0,0 +1,219 @@
import '../styles/EditingTools.css';
import { useEffect, useState } from 'react';
import logger from '@/lib/logger';
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPlaying?: boolean;
}
const EditingTools = ({
onSplit,
onReset,
onUndo,
onRedo,
onPlay,
canUndo,
canRedo,
isPlaying = false,
}: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => {
const checkScreenSize = () => {
setIsSmallScreen(window.innerWidth <= 640);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
// Handle play button click with iOS fix
const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== 'undefined') {
logger.debug('Play button clicked, current lastSeekedPosition:', window.lastSeekedPosition);
}
// Call the original handler
onPlay();
};
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Preview button */}
{/* <button
className="button preview-button"
onClick={onPreview}
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
style={{ fontSize: '0.875rem' }}
>
{isPreviewMode ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Preview</span>
</>
)}
</button> */}
{/* Standard Play button */}
<button
className="button play-button"
onClick={handlePlay}
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
style={{ fontSize: '0.875rem' }}
>
{isPlaying ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Pause</span>
<span className="short-text">Pause</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play</span>
<span className="short-text">Play</span>
</>
)}
</button>
{/* Segments Playback message (replaces play button during segments playback) */}
{/* {isPlayingSegments && !isSmallScreen && (
<div className="segments-playback-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="8" />
</svg>
Preview Mode
</div>
)} */}
{/* Preview mode message (replaces play button) */}
{/* {isPreviewMode && (
<div className="preview-mode-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="8" />
</svg>
Preview Mode
</div>
)} */}
</div>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
className="button"
aria-label="Undo"
data-tooltip="Undo last action"
disabled={!canUndo}
onClick={onUndo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>
<span className="button-text">Undo</span>
</button>
<button
className="button"
aria-label="Redo"
data-tooltip="Redo last undone action"
disabled={!canRedo}
onClick={onRedo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
className="button"
onClick={onReset}
data-tooltip="Reset to full video"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
<span className="reset-text">Reset</span>
</button>
</div>
</div>
</div>
);
};
export default EditingTools;

View File

@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import '../styles/IOSPlayPrompt.css';
interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>;
onPlay: () => void;
}
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
const [isVisible, setIsVisible] = useState(false);
// Check if the device is mobile or Safari browser
useEffect(() => {
const checkIsMobile = () => {
// More comprehensive check for mobile/tablet devices
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
};
// Only show for mobile devices
const isMobile = checkIsMobile();
setIsVisible(isMobile);
}, []);
// Close the prompt when video plays
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
// Just close the prompt when video plays
setIsVisible(false);
};
video.addEventListener('play', handlePlay);
return () => {
video.removeEventListener('play', handlePlay);
};
}, [videoRef]);
const handlePlayClick = () => {
onPlay();
// Prompt will be closed by the play event handler
};
if (!isVisible) return null;
return (
<div className="mobile-play-prompt-overlay">
<div className="mobile-play-prompt">
<button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing...
</button>
</div>
</div>
);
};
export default MobilePlayPrompt;

View File

@ -0,0 +1,197 @@
import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
}
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Clean up intervals on unmount
useEffect(() => {
return () => {
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
};
}, []);
// Get the video source URL from the main player
useEffect(() => {
let url = '';
if (videoRef.current && videoRef.current.querySelector('source')) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
if (source && source.src) {
url = source.src;
}
} else {
// Fallback to sample video if needed
url = '/videos/sample-video.mp4';
}
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]);
// Function to jump 15 seconds backward
const jumpBackward15 = () => {
if (iosVideoRef) {
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
iosVideoRef.currentTime = newTime;
}
};
// Function to jump 15 seconds forward
const jumpForward15 = () => {
if (iosVideoRef) {
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
iosVideoRef.currentTime = newTime;
}
};
// Start continuous 50ms increment when button is held
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
if (!iosVideoRef) return;
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
// First immediate adjustment
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
// Setup continuous adjustment
incrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
}
}, 100);
};
// Stop continuous increment
const stopIncrement = () => {
if (incrementIntervalRef.current) {
clearInterval(incrementIntervalRef.current);
incrementIntervalRef.current = null;
}
};
// Start continuous 50ms decrement when button is held
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
if (!iosVideoRef) return;
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// First immediate adjustment
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
// Setup continuous adjustment
decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
}
}, 100);
};
// Stop continuous decrement
const stopDecrement = () => {
if (decrementIntervalRef.current) {
clearInterval(decrementIntervalRef.current);
decrementIntervalRef.current = null;
}
};
return (
<div className="ios-video-player-container">
{/* Current Time / Duration Display */}
<div className="ios-time-display mb-2">
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* iOS-optimized Video Element with Native Controls */}
<video
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button
onClick={jumpBackward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
-15s
</button>
<button
onClick={jumpForward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
+15s
</button>
</div>
{/* iOS Fine Control Buttons */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
<button
onMouseDown={startDecrement}
onTouchStart={startDecrement}
onMouseUp={stopDecrement}
onMouseLeave={stopDecrement}
onTouchEnd={stopDecrement}
onTouchCancel={stopDecrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
-50ms
</button>
<button
onMouseDown={startIncrement}
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
onMouseLeave={stopIncrement}
onTouchEnd={stopIncrement}
onTouchCancel={stopIncrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
+50ms
</button>
</div>
<div className="ios-note mt-2 text-xs text-gray-500">
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
</div>
</div>
);
};
export default IOSVideoPlayer;

View File

@ -0,0 +1,74 @@
import React, { useEffect } from 'react';
import '../styles/Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
// Close modal when Escape key is pressed
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscapeKey);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Handle click outside the modal content to close it
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button className="modal-close-button" onClick={onClose} aria-label="Close modal" style={{ minWidth: '24px', minHeight: '24px' }}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-content">{children}</div>
{actions && <div className="modal-actions">{actions}</div>}
</div>
</div>
);
};
export default Modal;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,492 @@
import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import logger from '../lib/logger';
import '../styles/VideoPlayer.css';
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
isPlaying: boolean;
isMuted?: boolean;
onPlayPause: () => void;
onSeek: (time: number) => void;
onToggleMute?: () => void;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoRef,
currentTime,
duration,
isPlaying,
isMuted = false,
onPlayPause,
onSeek,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [lastPosition, setLastPosition] = useState<number | null>(null);
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({
x: 0,
});
const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device and Safari browser
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
const checkSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
setIsIOS(checkIOS());
// Store Safari detection globally for other components
if (typeof window !== 'undefined') {
(window as any).isSafari = checkSafari();
}
// Check if video was previously initialized
if (typeof window !== 'undefined') {
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
setHasInitialized(wasInitialized);
}
}, []);
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(true);
if (typeof window !== 'undefined') {
localStorage.setItem('video_initialized', 'true');
}
}
}, [isPlaying, hasInitialized]);
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x-webkit-airplay', 'allow');
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
}
}
};
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug('Video play event fired');
if (isIOS) {
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
}
};
const handlePause = () => {
logger.debug('Video pause event fired');
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking)
useEffect(() => {
setLastPosition(currentTime);
}, [currentTime]);
// Jump 10 seconds forward
const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration);
onSeek(newTime);
setLastPosition(newTime);
};
// Jump 10 seconds backward
const handleBackward = () => {
const newTime = Math.max(currentTime - 10, 0);
onSeek(newTime);
setLastPosition(newTime);
};
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle start of progress bar dragging
const handleProgressDragStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position
handleProgressDrag(e);
// Set up document-level event listeners for mouse movement and release
const handleMouseMove = (moveEvent: MouseEvent) => {
if (isDraggingProgressRef.current) {
handleProgressDrag(moveEvent);
}
};
const handleMouseUp = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Handle progress dragging for both mouse and touch events
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition;
// Update tooltip position and time
setTooltipPosition({
x: e.clientX,
});
setTooltipTime(seekTime);
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
};
// Handle touch events for progress bar
const handleProgressTouchStart = (e: React.TouchEvent) => {
if (!progressRef.current || !e.touches[0]) return;
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position using touch
handleProgressTouchMove(e);
// Set up document-level event listeners for touch movement and release
const handleTouchMove = (moveEvent: TouchEvent) => {
if (isDraggingProgressRef.current) {
handleProgressTouchMove(moveEvent);
}
};
const handleTouchEnd = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
// Handle touch dragging on progress bar
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
if (!progressRef.current) return;
// Get the touch coordinates
const touch = 'touches' in e ? e.touches[0] : null;
if (!touch) return;
e.preventDefault(); // Prevent scrolling while dragging
const rect = progressRef.current.getBoundingClientRect();
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
const seekTime = duration * touchPosition;
// Update tooltip position and time
setTooltipPosition({
x: touch.clientX,
});
setTooltipTime(seekTime);
// Store position for iOS Safari
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
};
// Handle click on progress bar (for non-drag interactions)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we're already dragging, don't handle the click
if (isDraggingProgress) return;
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
const seekTime = duration * clickPosition;
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
// Handle click on video to play/pause
const handleVideoClick = () => {
const video = videoRef.current;
if (!video) return;
// If the video is paused, we want to play it
if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) {
logger.debug('iOS: Explicitly setting position before play:', lastPosition);
// First, seek to the position
video.currentTime = lastPosition;
// Use a small timeout to ensure seeking is complete before play
setTimeout(() => {
if (videoRef.current) {
// Try to play with proper promise handling
videoRef.current
.play()
.then(() => {
logger.debug(
'iOS: Play started successfully at position:',
videoRef.current?.currentTime
);
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error('iOS: Error playing video:', err);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video
.play()
.then(() => {
logger.debug('Normal: Play started successfully');
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error('Error playing video:', err);
});
}
} else {
// If playing, pause and update state
video.pause();
onPlayPause();
}
};
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="metadata"
crossOrigin="anonymous"
onClick={handleVideoClick}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
poster={posterImage}
>
<source src={sampleVideoUrl} type="video/mp4" />
{/* Safari fallback for audio files */}
<source src={sampleVideoUrl} type="audio/mp4" />
<source src={sampleVideoUrl} type="audio/mpeg" />
<p>Your browser doesn't support HTML5 video or audio.</p>
</video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
{isIOS && !hasInitialized && !isPlaying && (
<div className="ios-first-play-indicator">
<div className="ios-play-message">Tap Play to initialize video controls</div>
</div>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
onClick={handleProgressClick}
onMouseDown={handleProgressDragStart}
onTouchStart={handleProgressTouchStart}
>
<div
className="video-progress-fill"
style={{
width: `${progressPercentage}%`,
}}
></div>
<div
className="video-scrubber"
style={{
left: `${progressPercentage}%`,
}}
></div>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<div
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: 'translateX(-50%)',
}}
>
{formatDetailedTime(tooltipTime)}
</div>
)}
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? 'Unmute' : 'Mute'}
onClick={onToggleMute}
data-tooltip={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
);
};
export default VideoPlayer;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,796 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: 20 14.3% 4.1%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--primary: 207 90% 54%;
--primary-foreground: 211 100% 99%;
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
--secondary-foreground: 60 9.1% 97.8%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
}
/* Video Player Styles */
.video-player {
position: relative;
width: 100%;
background-color: #000;
overflow: hidden;
border-radius: 0.5rem;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 1rem;
display: flex;
flex-direction: column;
}
.video-current-time {
color: #fff;
font-weight: 500;
}
.video-progress {
position: relative;
height: 4px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
margin-bottom: 1rem;
}
.video-progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: hsl(var(--primary));
border-radius: 2px;
}
.video-scrubber {
position: absolute;
width: 12px;
height: 12px;
margin-left: -6px;
background-color: white;
border-radius: 50%;
top: -4px;
}
/* Play/Pause indicator for video player */
.video-player-container {
position: relative;
overflow: hidden;
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
background-position: center;
background-repeat: no-repeat;
}
.play-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
}
.pause-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
}
/* Only show play/pause indicator on hover */
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
/* Timeline Styles */
.timeline-scroll-container {
height: 6rem;
border-radius: 0.375rem;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 0.75rem;
background-color: #eee; /* Very light gray background */
position: relative;
}
.timeline-container {
position: relative;
background-color: #eee; /* Very light gray background */
height: 6rem;
width: 100%;
cursor: pointer;
transition: width 0.3s ease;
}
.timeline-marker {
position: absolute;
top: -10px;
height: calc(100% + 10px);
width: 2px;
background-color: red;
z-index: 100; /* Highest z-index to stay on top of everything */
pointer-events: none; /* Allow clicks to pass through to segments underneath */
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
}
.trim-line-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
z-index: 10;
}
.trim-handle {
width: 8px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
position: absolute;
top: 0;
bottom: 0;
cursor: ew-resize;
z-index: 15;
}
.trim-handle.left {
left: -4px;
}
.trim-handle.right {
right: -4px;
}
.split-point {
position: absolute;
width: 2px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
top: 0;
bottom: 0;
z-index: 5;
}
/* Clip Segment styles */
.clip-segment {
position: absolute;
height: 95%;
top: 0;
border-radius: 4px;
background-size: cover;
background-position: center;
background-blend-mode: soft-light;
/* Border is now set in the color-specific rules */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
cursor: grab;
user-select: none;
transition:
box-shadow 0.2s,
transform 0.1s;
/* Original z-index for stacking order based on segment ID */
z-index: 15;
}
/* No background colors for segments, just borders with 2-color scheme */
.clip-segment:nth-child(odd),
.segment-color-1,
.segment-color-3,
.segment-color-5,
.segment-color-7 {
background-color: transparent;
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
}
.clip-segment:nth-child(even),
.segment-color-2,
.segment-color-4,
.segment-color-6,
.segment-color-8 {
background-color: transparent;
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
}
.clip-segment:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
filter: brightness(1.1);
}
.clip-segment:active {
cursor: grabbing;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transform: translateY(0);
}
.clip-segment.selected {
border-width: 3px; /* Make border thicker instead of changing color */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 25;
filter: brightness(1.2);
}
.clip-segment-info {
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
color: #000000; /* Pure black text */
padding: 6px 8px;
font-size: 0.7rem;
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 4px 4px 0 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
}
.clip-segment-name {
font-weight: bold;
color: #000000; /* Pure black text */
}
.clip-segment-time {
font-size: 0.65rem;
color: #000000; /* Pure black text */
}
.clip-segment-duration {
font-size: 0.65rem;
color: #000000; /* Pure black text */
background: rgba(179, 217, 255, 0.4); /* Light blue background */
padding: 1px 4px;
border-radius: 2px;
display: inline-block;
margin-top: 2px;
}
.clip-segment-handle {
position: absolute;
width: 8px;
top: 0;
bottom: 0;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
cursor: ew-resize;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.clip-segment-handle::after {
content: "↔";
color: white;
font-size: 12px;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
}
.clip-segment-handle.left {
left: 0;
}
.clip-segment-handle.right {
right: 0;
}
.clip-segment-handle:hover {
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
width: 10px;
}
/* Zoom Slider */
input[type="range"] {
-webkit-appearance: none;
height: 6px;
background: #e0e0e0;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
cursor: pointer;
}
/* Tooltip styles */
[data-tooltip] {
position: relative;
cursor: pointer;
}
[data-tooltip]::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.8rem;
white-space: nowrap;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
margin-bottom: 0px;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips (simple hover labels) on touch devices */
@media (pointer: coarse) {
[data-tooltip]::before,
[data-tooltip]::after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
/* Fix for buttons with disabled state */
button[disabled][data-tooltip]::before,
button[disabled][data-tooltip]::after {
opacity: 0.5;
}
/* Custom tooltip for action buttons - completely different approach */
.tooltip-action-btn {
position: relative;
}
.tooltip-action-btn[data-tooltip]::before,
.tooltip-action-btn[data-tooltip]::after {
/* Reset standard tooltip styles first */
opacity: 0;
visibility: hidden;
position: absolute;
pointer-events: none;
transition: all 0.3s ease;
}
.tooltip-action-btn[data-tooltip]::before {
content: attr(data-tooltip);
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
padding: 4px 8px;
border-radius: 3px;
white-space: nowrap;
/* Position below the button */
bottom: -35px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
.tooltip-action-btn[data-tooltip]::after {
content: "";
border-width: 5px;
border-style: solid;
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
/* Position the arrow */
bottom: -15px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn:hover[data-tooltip]::before,
.tooltip-action-btn:hover[data-tooltip]::after {
opacity: 1;
visibility: visible;
}
}
/* Ensure tooltip container has proper space */
/* Segment tooltip styles */
.segment-tooltip {
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
color: #000000; /* Pure black text */
border-radius: 4px;
padding: 6px; /* Regular padding now that we have custom tooltips */
min-width: 140px; /* Increased width to accommodate the new delete button */
z-index: 1000; /* Increased z-index */
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.segment-tooltip::after {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
}
.tooltip-time {
font-size: 0.85rem;
font-weight: bold;
text-align: center;
margin-bottom: 6px;
color: #000000; /* Pure black text */
}
.tooltip-actions {
display: flex;
justify-content: space-between;
gap: 5px;
position: relative;
}
.tooltip-action-btn {
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
border: none;
border-radius: 3px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 6px;
transition: background-color 0.2s;
min-width: 20px !important;
}
.tooltip-action-btn:hover {
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
}
.tooltip-action-btn svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
/* Adjust for the hand icons specifically */
.tooltip-action-btn.set-in svg,
.tooltip-action-btn.set-out svg {
width: 100%;
height: 100%;
margin: 0 auto;
fill: currentColor;
stroke: none;
}
/* Empty space tooltip styling */
.empty-space-tooltip {
background-color: white;
border-radius: 6px;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px;
z-index: 50;
min-width: 120px;
text-align: center;
position: relative;
}
.empty-space-tooltip::after {
content: "";
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 8px 8px 0;
border-style: solid;
border-color: white transparent transparent;
}
.tooltip-action-btn.new-segment {
width: auto;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 5px;
}
.tooltip-btn-text {
font-size: 0.8rem;
white-space: nowrap;
color: #000000; /* Pure black text */
}
.icon-new-segment {
width: 20px;
height: 20px;
}
/* Zoom dropdown styling */
.zoom-dropdown-container {
position: relative;
}
.zoom-button {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.zoom-button:hover {
background-color: rgba(108, 117, 125, 1);
}
.zoom-dropdown {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
}
.zoom-option {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.zoom-option:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.zoom-option.selected {
background-color: rgba(0, 123, 255, 0.2);
font-weight: 500;
}
/* Save buttons styling */
.save-button,
.save-copy-button,
.save-segments-button {
background-color: rgba(0, 123, 255, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-button:hover,
.save-copy-button:hover {
background-color: rgba(0, 123, 255, 1);
}
.save-copy-button {
background-color: rgba(108, 117, 125, 0.8);
}
.save-copy-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Time navigation input styling */
.time-nav-label {
font-weight: 500;
font-size: 0.9rem;
}
.time-input {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #ccc;
width: 150px;
font-family: monospace;
}
.time-button-group {
display: flex;
gap: 5px;
}
.time-button {
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 6px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background-color 0.2s;
}
.time-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Timeline navigation and zoom controls responsiveness */
.timeline-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
padding: 12px;
background-color: #f5f5f5;
border-radius: 6px;
margin-top: 15px;
}
.time-navigation {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.controls-right {
display: flex;
align-items: center;
gap: 10px;
}
/* Media queries for responsive design */
@media (max-width: 768px) {
.timeline-controls {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.controls-right {
margin-top: 10px;
width: 100%;
justify-content: flex-start;
text-align: center;
align-items: center;
justify-content: center;
}
}
/* Timeline header styling */
.timeline-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.timeline-title {
font-weight: bold;
margin-right: 20px;
}
.timeline-title-text {
font-size: 1.1rem;
}
.current-time,
.duration-time {
white-space: nowrap;
}
.time-code {
font-family: monospace;
font-weight: 500;
}
@media (max-width: 480px) {
.timeline-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.time-navigation {
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.time-button-group {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.controls-right {
flex-wrap: wrap;
gap: 8px;
}
.save-button,
.save-copy-button {
margin-top: 8px;
width: 100%;
}
.zoom-dropdown-container {
width: 100%;
}
.zoom-button {
width: 100%;
justify-content: center;
}
}

View File

@ -0,0 +1,31 @@
/**
* A consistent logger utility that only outputs debug messages in development
* but always shows errors, warnings, and info messages.
*/
const logger = {
/**
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(...args);
}
},
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args),
};
export default logger;

View File

@ -0,0 +1,51 @@
import { QueryClient, QueryFunction } from '@tanstack/react-query';
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: 'include',
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = 'returnNull' | 'throw';
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: 'include',
});
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: 'throw' }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});

View File

@ -0,0 +1,34 @@
/**
* Format seconds to HH:MM:SS.mmm format with millisecond precision
*/
export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return '00:00:00.000';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, '0');
const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
};
/**
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
*/
export const formatTime = (seconds: number): string => {
// Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds);
};
/**
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
*/
export const formatLongTime = (seconds: number): string => {
// Use the detailed format
return formatDetailedTime(seconds);
};

View File

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

View File

@ -0,0 +1,17 @@
/**
* Generate a solid color background for a segment
* Returns a CSS color based on the segment position
*/
export const generateSolidColor = (time: number, duration: number): string => {
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};

View File

@ -0,0 +1,39 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
if (typeof window !== 'undefined') {
window.MEDIA_DATA = {
videoUrl: '',
mediaId: '',
posterUrl: ''
};
window.lastSeekedPosition = 0;
}
declare global {
interface Window {
MEDIA_DATA: {
videoUrl: string;
mediaId: string;
posterUrl?: string;
};
seekToFunction?: (time: number) => void;
lastSeekedPosition: number;
}
}
// Mount the components when the DOM is ready
const mountComponents = () => {
const chaptersEditorContainer = document.getElementById('chapters-editor-root');
if (chaptersEditorContainer) {
const chaptersEditorRoot = createRoot(chaptersEditorContainer);
chaptersEditorRoot.render(<App />);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountComponents);
} else {
mountComponents();
}

View File

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

View File

@ -0,0 +1,338 @@
#chapters-editor-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.clip-segments-container {
margin-top: 1rem;
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.clip-segments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin: 0;
}
.save-chapters-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
&.has-changes {
background-color: #10b981;
animation: pulse-green 2s infinite;
}
&.has-changes:hover {
background-color: #059669;
}
svg {
width: 1rem;
height: 1rem;
}
}
@keyframes pulse-green {
0%,
100% {
background-color: #10b981;
}
50% {
background-color: #34d399;
}
}
.chapter-editor {
background-color: #f8fafc;
border: 2px solid #3b82f6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.2s ease;
}
.chapter-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.chapter-editor-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.chapter-editor-segment {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.chapter-title-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
resize: vertical;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.chapter-editor-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
.segment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background-color: rgba(59, 130, 246, 0.05);
}
}
.segment-content {
display: flex;
align-items: center;
}
.segment-info {
display: flex;
flex-direction: column;
}
.segment-title {
font-weight: 500;
font-size: 0.875rem;
color: black;
}
.chapter-title {
color: #1f2937;
font-weight: 600;
}
.default-title {
color: #6b7280;
font-style: italic;
}
.segment-time {
font-size: 0.75rem;
color: black;
}
.segment-duration {
font-size: 0.75rem;
margin-top: 0.25rem;
display: inline-block;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
color: black;
}
.segment-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
padding: 0.375rem;
color: #4b5563;
background-color: #e5e7eb;
border-radius: 9999px;
border: none;
cursor: pointer;
transition:
background-color 0.2s,
color 0.2s;
min-width: auto;
&:hover {
color: black;
background-color: #d1d5db;
}
svg {
height: 1rem;
width: 1rem;
}
}
.empty-message {
padding: 1rem;
text-align: center;
color: rgba(51, 51, 51, 0.7);
}
.segment-color-1 {
background-color: rgba(59, 130, 246, 0.15);
}
.segment-color-2 {
background-color: rgba(16, 185, 129, 0.15);
}
.segment-color-3 {
background-color: rgba(245, 158, 11, 0.15);
}
.segment-color-4 {
background-color: rgba(239, 68, 68, 0.15);
}
.segment-color-5 {
background-color: rgba(139, 92, 246, 0.15);
}
.segment-color-6 {
background-color: rgba(236, 72, 153, 0.15);
}
.segment-color-7 {
background-color: rgba(6, 182, 212, 0.15);
}
.segment-color-8 {
background-color: rgba(250, 204, 21, 0.15);
}
/* Responsive styles */
@media (max-width: 768px) {
.clip-segments-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.save-chapters-button {
justify-content: center;
}
.chapter-editor-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.chapter-editor-segment {
align-self: stretch;
text-align: center;
}
}
}

View File

@ -0,0 +1,397 @@
#chapters-editor-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.editing-tools-container {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 2.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
gap: 15px;
width: 100%;
}
.flex-container.single-row {
flex-wrap: nowrap;
}
/* Show full text on larger screens, hide short text */
.full-text {
display: inline;
}
.short-text {
display: none;
}
/* Reset text always visible by default */
.reset-text {
display: inline;
}
.button-group {
display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
color: #333;
background: none;
border: none;
cursor: pointer;
min-width: auto;
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.25rem;
}
}
}
.divider {
border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
}
/* Style for play buttons with highlight effect */
.play-button,
.preview-button {
font-weight: 600;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
min-width: 80px;
justify-content: center;
font-size: 0.875rem !important;
}
/* Greyed out play button when segments are playing */
.play-button.greyed-out {
opacity: 0.5;
cursor: not-allowed;
}
/* Highlighted stop button with blue pulse on small screens */
.segments-button.highlighted-stop {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid #3b82f6;
animation: bluePulse 2s infinite;
}
@keyframes bluePulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled),
.preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
font-size: 0.875rem !important;
width: auto !important;
background: none !important;
}
.play-button svg,
.preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
.button-group.secondary .button-text {
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button,
.play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* Prevent container overflow on mobile */
.editing-tools-container {
padding: 0.75rem;
overflow-x: hidden;
}
/* At this breakpoint, make preview button text shorter */
.preview-button {
min-width: auto;
}
/* Switch to short text versions */
.full-text {
display: none;
}
.short-text {
display: inline;
margin-left: 0.15rem;
}
/* Hide reset text */
.reset-text {
display: none;
}
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
flex-shrink: 0;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
flex-shrink: 0;
}
/* Reduce button sizes on mobile */
.button-group button {
padding: 0.375rem;
min-width: auto;
}
.button-group button svg {
height: 1.125rem;
width: 1.125rem;
margin-right: 0.125rem;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
justify-content: space-between;
flex-wrap: nowrap;
gap: 10px;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.editing-tools-container {
padding: 0.5rem;
}
.flex-container.single-row {
gap: 8px;
}
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
/* Even smaller buttons on very small screens */
.button-group button {
padding: 0.125rem;
}
.button-group button svg {
height: 1rem;
width: 1rem;
margin-right: 0;
}
/* Hide all button text on very small screens */
.button-text,
.reset-text {
display: none;
}
}
/* Portrait orientation specific fixes */
@media (max-width: 640px) and (orientation: portrait) {
.editing-tools-container {
width: 100%;
box-sizing: border-box;
}
.flex-container.single-row {
width: 100%;
padding: 0;
margin: 0;
}
/* Ensure button groups don't overflow */
.button-group {
max-width: 50%;
}
.button-group.play-buttons-group {
max-width: 60%;
}
.button-group.secondary {
max-width: 40%;
}
}
}

View File

@ -0,0 +1,167 @@
.ios-notification {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: #fffdeb;
border-bottom: 1px solid #e2e2e2;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
animation: slide-down 0.5s ease-in-out;
}
@keyframes slide-down {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
.ios-notification-content {
max-width: 600px;
margin: 0 auto;
display: flex;
align-items: flex-start;
position: relative;
padding: 0 10px;
}
.ios-notification-icon {
flex-shrink: 0;
color: #0066cc;
margin-right: 15px;
margin-top: 3px;
}
.ios-notification-message {
flex-grow: 1;
}
.ios-notification-message h3 {
margin: 0 0 5px 0;
font-size: 16px;
font-weight: 600;
color: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.ios-notification-message p {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.ios-notification-message ol {
margin: 0;
padding-left: 20px;
font-size: 14px;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.ios-notification-message li {
margin-bottom: 3px;
}
.ios-notification-close {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.ios-notification-close:hover {
color: #000;
}
/* Desktop mode button styling */
.ios-mode-options {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 8px;
}
.ios-desktop-mode-btn {
background-color: #0066cc;
color: white;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin-bottom: 6px;
cursor: pointer;
transition: background-color 0.2s;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.ios-desktop-mode-btn:hover {
background-color: #0055aa;
}
.ios-desktop-mode-btn:active {
background-color: #004499;
transform: scale(0.98);
}
.ios-or {
font-size: 12px;
color: #666;
margin: 0 0 6px 0;
font-style: italic;
}
/* iOS-specific styles */
@supports (-webkit-touch-callout: none) {
.ios-notification {
padding-top: env(safe-area-inset-top);
}
.ios-notification-close {
padding: 10px;
}
}
/* Make sure this notification has better visibility on smaller screens */
@media (max-width: 480px) {
.ios-notification-content {
padding: 5px;
}
.ios-notification-message h3 {
font-size: 15px;
}
.ios-notification-message p,
.ios-notification-message ol {
font-size: 13px;
}
}
/* Add iOS-specific styles when in desktop mode */
html.ios-device {
/* Force the content to be rendered at desktop width */
min-width: 1024px;
overflow-x: auto;
}
html.ios-device .ios-control-btn {
/* Make buttons easier to tap in desktop mode */
min-height: 44px;
}

View File

@ -0,0 +1,96 @@
.mobile-play-prompt-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.mobile-play-prompt {
background-color: white;
width: 90%;
max-width: 400px;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
text-align: center;
}
.mobile-play-prompt h3 {
margin: 0 0 15px 0;
font-size: 20px;
color: #333;
font-weight: 600;
}
.mobile-play-prompt p {
margin: 0 0 15px 0;
font-size: 16px;
color: #444;
line-height: 1.5;
}
.mobile-prompt-instructions {
margin: 20px 0;
text-align: left;
background-color: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.mobile-prompt-instructions p {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 500;
}
.mobile-prompt-instructions ol {
margin: 0;
padding-left: 22px;
}
.mobile-prompt-instructions li {
margin-bottom: 8px;
font-size: 14px;
color: #333;
}
.mobile-play-button {
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
padding: 12px 25px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
margin-top: 5px;
/* Make button easier to tap on mobile */
min-height: 44px;
min-width: 200px;
}
.mobile-play-button:hover {
background-color: #0069d9;
}
.mobile-play-button:active {
background-color: #0062cc;
transform: scale(0.98);
}
/* Special styles for mobile devices */
@supports (-webkit-touch-callout: none) {
.mobile-play-button {
/* Extra spacing for mobile */
padding: 14px 25px;
}
}

View File

@ -0,0 +1,94 @@
.ios-video-player-container {
position: relative;
background-color: #f8f8f8;
border: 1px solid #e2e2e2;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
overflow: hidden;
}
.ios-video-player-container video {
width: 100%;
height: auto;
max-height: 360px;
aspect-ratio: 16/9;
background-color: black;
}
.ios-time-display {
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #333;
}
.ios-note {
text-align: center;
color: #777;
font-size: 0.8rem;
padding: 0.5rem 0;
}
/* iOS-specific styling tweaks */
@supports (-webkit-touch-callout: none) {
.ios-video-player-container video {
max-height: 50vh; /* Use viewport height on iOS */
}
/* Improve controls visibility on iOS */
video::-webkit-media-controls {
opacity: 1 !important;
visibility: visible !important;
}
/* Ensure controls don't disappear too quickly */
video::-webkit-media-controls-panel {
transition-duration: 3s !important;
}
}
/* External controls styling */
.ios-external-controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.ios-control-btn {
font-weight: bold;
min-width: 100px;
height: 44px; /* Minimum touch target size for iOS */
border: none;
border-radius: 8px;
transition: all 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
}
.ios-control-btn:active {
transform: scale(0.98);
opacity: 0.9;
}
/* Prevent text selection on buttons */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
cursor: default;
}
/* Specifically prevent default behavior on fine controls */
.ios-fine-controls button,
.ios-external-controls .no-select {
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
pointer-events: auto;
}

View File

@ -0,0 +1,306 @@
#chapters-editor-root {
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.modal-close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.modal-close-button:hover {
color: #000;
}
.modal-content {
padding: 20px;
color: #333;
font-size: 1rem;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
}
.modal-button {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-button-primary {
background-color: #0066cc;
color: white;
}
.modal-button-primary:hover {
background-color: #0055aa;
}
.modal-button-secondary {
background-color: #f0f0f0;
color: #333;
}
.modal-button-secondary:hover {
background-color: #e0e0e0;
}
.modal-button-danger {
background-color: #dc3545;
color: white;
}
.modal-button-danger:hover {
background-color: #bd2130;
}
/* Modal content styles */
.modal-message {
margin-bottom: 16px;
font-size: 1rem;
}
.text-center {
text-align: center;
}
.modal-spinner {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #0066cc;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.modal-success-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #28a745;
font-size: 2rem;
}
.modal-success-icon svg {
width: 60px;
height: 60px;
color: #4caf50;
animation: success-pop 0.5s ease-out;
}
@keyframes success-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-error-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #dc3545;
font-size: 2rem;
}
.modal-error-icon svg {
width: 60px;
height: 60px;
color: #f44336;
animation: error-pop 0.5s ease-out;
}
@keyframes error-pop {
0% {
transform: scale(0);
opacity: 0;
}
70% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.modal-choice-button {
padding: 12px 16px;
border: none;
border-radius: 4px;
background-color: #0066cc;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
color: white;
}
.modal-choice-button:hover {
background-color: #0055aa;
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.modal-choice-button svg {
margin-right: 8px;
}
.success-link {
background-color: #4caf50;
}
.success-link:hover {
background-color: #3d8b40;
}
.centered-choice {
margin: 0 auto;
width: auto;
min-width: 220px;
background-color: #0066cc;
color: white;
}
.centered-choice:hover {
background-color: #0055aa;
}
@media (max-width: 480px) {
.modal-container {
width: 95%;
}
.modal-actions {
flex-direction: column;
}
.modal-button {
width: 100%;
}
}
.error-message {
color: #f44336;
font-weight: 500;
background-color: rgba(244, 67, 54, 0.1);
padding: 10px;
border-radius: 4px;
border-left: 4px solid #f44336;
margin-top: 10px;
}
.redirect-message {
margin-top: 20px;
color: #555;
font-size: 0.95rem;
padding: 0;
margin: 0;
}
.countdown {
font-weight: bold;
color: #0066cc;
font-size: 1.1rem;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,341 @@
.two-row-tooltip {
display: flex;
flex-direction: column;
background-color: white;
padding: 6px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 3000; /* Highest z-index to ensure it's above all other elements */
}
/* Hide ±100ms buttons for more compact tooltip */
.tooltip-time-btn[data-tooltip="Decrease by 100ms"],
.tooltip-time-btn[data-tooltip="Increase by 100ms"] {
display: none !important;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
}
.tooltip-row:first-child {
margin-bottom: 6px;
}
.tooltip-time-btn {
background-color: #f0f0f0 !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
color: #333 !important;
cursor: pointer !important;
transition: background-color 0.2s !important;
min-width: 20px !important;
}
.tooltip-time-btn:hover {
background-color: #e0e0e0 !important;
}
.tooltip-time-display {
font-family: monospace !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
color: #333 !important;
padding: 4px 6px !important;
background-color: #f7f7f7 !important;
border-radius: 4px !important;
min-width: 100px !important;
text-align: center !important;
overflow: hidden !important;
}
/* Disabled state for time display */
.tooltip-time-display.disabled {
pointer-events: none !important;
cursor: not-allowed !important;
opacity: 0.6 !important;
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
/* Force disabled tooltips to show on hover for better user feedback */
.tooltip-time-btn.disabled[data-tooltip]:hover:before,
.tooltip-time-btn.disabled[data-tooltip]:hover:after,
.tooltip-action-btn.disabled[data-tooltip]:hover:before,
.tooltip-action-btn.disabled[data-tooltip]:hover:after {
opacity: 1 !important;
visibility: visible !important;
}
.tooltip-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
position: relative;
z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
margin-top: 5px;
}
.tooltip-action-btn {
background-color: #f3f4f6;
border: none;
border-radius: 4px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
width: 26px;
height: 26px;
min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */
}
/* Custom tooltip styles for second row action buttons - positioned below */
.tooltip-action-btn[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
height: 30px;
top: 35px; /* Position below the button with increased space */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
margin-left: 0; /* Reset margin */
background-color: rgba(0, 0, 0, 0.85);
color: white;
text-align: left;
padding: 6px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after {
content: "";
position: absolute;
top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
border-width: 4px;
border-style: solid;
/* Arrow pointing down from button to tooltip */
border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
margin-left: 0; /* Reset margin */
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Show tooltips on hover - but only on devices with hover capability (desktops) */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn[data-tooltip]:hover:before,
.tooltip-action-btn[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
@media (pointer: coarse) {
.tooltip-action-btn[data-tooltip]:before,
.tooltip-action-btn[data-tooltip]:after {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
content: none !important;
}
}
.tooltip-action-btn:hover {
background-color: #e5e7eb;
color: #111827;
}
.tooltip-action-btn.delete {
color: #ef4444;
}
.tooltip-action-btn.delete:hover {
background-color: #fee2e2;
}
.tooltip-action-btn.play {
color: #10b981;
}
.tooltip-action-btn.play:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.pause {
color: #3b82f6;
}
.tooltip-action-btn.pause:hover {
background-color: #dbeafe;
}
.tooltip-action-btn.play-from-start {
color: #4f46e5;
}
.tooltip-action-btn.play-from-start:hover {
background-color: #e0e7ff;
}
.tooltip-action-btn svg {
width: 16px;
height: 16px;
}
/* Adjust the new segment button style */
.tooltip-action-btn.new-segment {
width: auto;
height: auto;
padding: 6px 10px;
display: flex;
flex-direction: row;
color: #10b981;
}
.tooltip-action-btn.new-segment:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 6px;
font-size: 0.75rem;
white-space: nowrap;
}
/* Disabled state for tooltip action buttons */
.tooltip-action-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f3f4f6;
}
.tooltip-action-btn.disabled:hover {
background-color: #f3f4f6;
color: #9ca3af;
}
.tooltip-action-btn.disabled svg {
color: #9ca3af;
}
.tooltip-action-btn.disabled .tooltip-btn-text {
color: #9ca3af;
}
/* Ensure pause button is properly styled when disabled */
.tooltip-action-btn.pause.disabled {
color: #9ca3af !important;
opacity: 0.5;
cursor: not-allowed;
}
.tooltip-action-btn.pause.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Ensure play button is properly styled when disabled */
.tooltip-action-btn.play.disabled {
color: #9ca3af !important;
opacity: 0.5;
cursor: not-allowed;
}
.tooltip-action-btn.play.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Ensure time adjustment buttons are properly styled when disabled */
.tooltip-time-btn.disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
.tooltip-time-btn.disabled:hover {
background-color: #f3f4f6 !important;
color: #9ca3af !important;
}
/* Additional mobile optimizations */
@media (max-width: 768px) {
.two-row-tooltip {
padding: 4px;
}
.tooltip-row:first-child {
margin-bottom: 4px;
}
.tooltip-time-btn {
min-width: 20px !important;
font-size: 0.7rem !important;
padding: 3px 6px !important;
}
.tooltip-time-display {
font-size: 0.8rem !important;
padding: 3px 4px !important;
min-width: 90px !important;
}
.tooltip-action-btn {
width: 24px;
height: 24px;
padding: 4px;
}
.tooltip-action-btn.new-segment {
padding: 4px 8px;
}
.tooltip-action-btn svg {
width: 14px;
height: 14px;
}
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before {
min-width: 100px;
font-size: 11px;
padding: 4px 8px;
height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */
}
.tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */
}
}

View File

@ -0,0 +1,342 @@
#chapters-editor-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.video-player-container {
position: relative;
width: 100%;
background: #000;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
aspect-ratio: 16/9;
/* Prevent iOS Safari from showing default video controls */
-webkit-user-select: none;
user-select: none;
}
.video-player-container video {
width: 100%;
height: 100%;
cursor: pointer;
/* Force hardware acceleration */
transform: translateZ(0);
-webkit-transform: translateZ(0);
/* Prevent iOS Safari from showing default video controls */
-webkit-user-select: none;
user-select: none;
}
/* iOS-specific styles */
@supports (-webkit-touch-callout: none) {
.video-player-container video {
/* Additional iOS optimizations */
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.play-pause-indicator::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.play-pause-indicator.play-icon::before {
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 25px solid white;
margin-left: 3px;
}
.play-pause-indicator.pause-icon::before {
width: 20px;
height: 25px;
border-left: 6px solid white;
border-right: 6px solid white;
}
/* iOS First-play indicator */
.ios-first-play-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.ios-play-message {
color: white;
font-size: 1.2rem;
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
border-radius: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.3s;
}
.video-player-container:hover .video-controls {
opacity: 1;
}
.video-current-time {
color: white;
font-size: 0.875rem;
}
.video-duration {
color: white;
font-size: 0.875rem;
}
.video-time-display {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
color: white;
font-size: 0.875rem;
}
.video-progress {
position: relative;
height: 6px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
margin: 0 10px;
touch-action: none; /* Prevent browser handling of drag gestures */
flex-grow: 1;
}
.video-progress.dragging {
height: 8px;
}
.video-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #ff0000;
border-radius: 3px;
pointer-events: none;
}
.video-scrubber {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background-color: #ff0000;
border-radius: 50%;
cursor: grab;
transition:
transform 0.1s ease,
width 0.1s ease,
height 0.1s ease;
}
/* Make the scrubber larger when dragging for better control */
.video-progress.dragging .video-scrubber {
transform: translate(-50%, -50%) scale(1.2);
width: 18px;
height: 18px;
cursor: grabbing;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
}
/* Enhance for touch devices */
@media (pointer: coarse) {
.video-scrubber {
width: 20px;
height: 20px;
}
.video-progress.dragging .video-scrubber {
width: 24px;
height: 24px;
}
/* Create a larger invisible touch target */
.video-scrubber:before {
content: "";
position: absolute;
top: -10px;
left: -10px;
right: -10px;
bottom: -10px;
}
}
.video-controls-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.mute-button,
.fullscreen-button {
min-width: auto;
color: white;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
svg {
width: 1.25rem;
height: 1.25rem;
}
}
/* Time tooltip that appears when dragging */
.video-time-tooltip {
position: absolute;
top: -30px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Add a small arrow to the tooltip */
.video-time-tooltip:after {
content: "";
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(0, 0, 0, 0.7);
}
}

View File

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

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,47 @@
{
"name": "video-trim-js",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc",
"build:django": "vite build --config vite.chapters-editor.config.ts --outDir ../../../static/chapters_editor",
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
},
"dependencies": {
"@tanstack/react-query": "^5.74.4",
"clsx": "^2.1.1",
"express": "^4.21.2",
"express-session": "^1.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.3",
"zod": "^3.24.3"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.17.30",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"prettier": "^3.6.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.18"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
}
}

View File

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

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const insertUserSchema = z.object({
username: z.string(),
password: z.string(),
});
export type InsertUser = z.infer<typeof insertUserSchema>;
export type User = InsertUser & { id: number };

View File

@ -0,0 +1,90 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;

View File

@ -0,0 +1,22 @@
{
"include": ["client/src/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"noEmit": true,
"module": "ESNext",
"strict": true,
"lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
}
}
}

View File

@ -0,0 +1,56 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'client', 'src'),
},
},
root: path.resolve(__dirname, 'client'),
define: {
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'),
},
},
build: {
minify: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'client/src/main.tsx'),
name: 'ChaptersEditor',
formats: ['iife'],
fileName: () => 'chapters-editor.js',
},
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
// Keep original names for image assets
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
return assetInfo.name;
}
return assetInfo.name || 'asset-[hash][extname]';
},
// Inline small assets, emit larger ones
inlineDynamicImports: true,
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
// Output to Django's static directory
outDir: '../../../static/video_editor',
emptyOutDir: true,
external: ['react', 'react-dom'],
// Inline assets smaller than 100KB, emit larger ones
assetsInlineLimit: 102400,
},
});

View File

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
// Get current directory
const __dirname = path.resolve();
export default defineConfig({
plugins: [
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "client", "src"),
},
},
root: path.resolve(__dirname, "client"),
build: {
outDir: path.resolve(__dirname, "dist/public"),
emptyOutDir: true,
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

View File

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

View File

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

View File

@ -1,85 +1,83 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils";
import "../styles/ClipSegments.css";
import { formatTime, formatLongTime } from '@/lib/timeUtils';
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
name: string;
startTime: number;
endTime: number;
thumbnail: string;
id: number;
name: string;
startTime: number;
endTime: number;
thumbnail: string;
}
interface ClipSegmentsProps {
segments: Segment[];
segments: Segment[];
}
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// 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);
};
// 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}`;
};
// Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8
// This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
{sortedSegments.map((segment, index) => (
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
<div className="segment-content">
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">Segment {index + 1}</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div>
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
{sortedSegments.map((segment, index) => (
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
<div className="segment-content">
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">Segment {index + 1}</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div>
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
)}
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.
</div>
)}
</div>
);
);
};
export default ClipSegments;

View File

@ -1,108 +1,109 @@
import "../styles/EditingTools.css";
import { useEffect, useState } from "react";
import '../styles/EditingTools.css';
import { useEffect, useState } from 'react';
import logger from '@/lib/logger';
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPlaySegments: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPlaying?: boolean;
isPlayingSegments?: boolean;
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPlaySegments: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPlaying?: boolean;
isPlayingSegments?: boolean;
}
const EditingTools = ({
onSplit,
onReset,
onUndo,
onRedo,
onPlaySegments,
onPlay,
canUndo,
canRedo,
isPlaying = false,
isPlayingSegments = false
onSplit,
onReset,
onUndo,
onRedo,
onPlaySegments,
onPlay,
canUndo,
canRedo,
isPlaying = false,
isPlayingSegments = false,
}: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false);
const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => {
const checkScreenSize = () => {
setIsSmallScreen(window.innerWidth <= 640);
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();
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Segments button */}
<button
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={
isPlayingSegments ? 'Stop segments playback' : 'Play segments in one continuous flow'
}
style={{ fontSize: '0.875rem' }}
>
{isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop Preview</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Play Preview</span>
</>
)}
</button>
// Handle play button click with iOS fix
const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== "undefined") {
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
}
// Call the original handler
onPlay();
};
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Segments button */}
<button
className={`button segments-button`}
onClick={onPlaySegments}
data-tooltip={
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
}
style={{ fontSize: "0.875rem" }}
>
{isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop Preview</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Play Preview</span>
</>
)}
</button>
{/* Play Preview button */}
{/* <button
{/* Play Preview button */}
{/* <button
className="button preview-button"
onClick={onPreview}
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
@ -130,56 +131,56 @@ const EditingTools = ({
)}
</button> */}
{/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && (
<button
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
onClick={handlePlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: "0.875rem" }}
disabled={isPlayingSegments}
>
{isPlaying && !isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Pause</span>
<span className="short-text">Pause</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play</span>
<span className="short-text">Play</span>
</>
)}
</button>
)}
{/* Standard Play button (only shown when not in segments playback on small screens) */}
{(!isPlayingSegments || !isSmallScreen) && (
<button
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
onClick={handlePlay}
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
style={{ fontSize: '0.875rem' }}
disabled={isPlayingSegments}
>
{isPlaying && !isPlayingSegments ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Pause</span>
<span className="short-text">Pause</span>
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play</span>
<span className="short-text">Play</span>
</>
)}
</button>
)}
{/* Segments Playback message (replaces play button during segments playback) */}
{/* {isPlayingSegments && !isSmallScreen && (
{/* Segments Playback message (replaces play button during segments playback) */}
{/* {isPlayingSegments && !isSmallScreen && (
<div className="segments-playback-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
@ -190,8 +191,8 @@ const EditingTools = ({
</div>
)} */}
{/* Preview mode message (replaces play button) */}
{/* {isPreviewMode && (
{/* Preview mode message (replaces play button) */}
{/* {isPreviewMode && (
<div className="preview-mode-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
@ -201,72 +202,72 @@ const EditingTools = ({
Preview Mode
</div>
)} */}
</div>
</div>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
className="button"
aria-label="Undo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
disabled={!canUndo || isPlayingSegments}
onClick={onUndo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>
<span className="button-text">Undo</span>
</button>
<button
className="button"
aria-label="Redo"
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
disabled={!canRedo || isPlayingSegments}
onClick={onRedo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
className="button"
onClick={onReset}
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
disabled={isPlayingSegments}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
<span className="reset-text">Reset</span>
</button>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
className="button"
aria-label="Undo"
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'}
disabled={!canUndo || isPlayingSegments}
onClick={onUndo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 14 4 9l5-5" />
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
</svg>
<span className="button-text">Undo</span>
</button>
<button
className="button"
aria-label="Redo"
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Redo last undone action'}
disabled={!canRedo || isPlayingSegments}
onClick={onRedo}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 14 5-5-5-5" />
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
className="button"
onClick={onReset}
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Reset to full video'}
disabled={isPlayingSegments}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
<span className="reset-text">Reset</span>
</button>
</div>
</div>
</div>
</div>
</div>
);
);
};
export default EditingTools;

View File

@ -1,55 +1,55 @@
import React, { useState, useEffect } from "react";
import "../styles/IOSPlayPrompt.css";
import React, { useState, useEffect } from 'react';
import '../styles/IOSPlayPrompt.css';
interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>;
onPlay: () => void;
videoRef: React.RefObject<HTMLVideoElement>;
onPlay: () => void;
}
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
const [isVisible, setIsVisible] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Check if the device is mobile
useEffect(() => {
const checkIsMobile = () => {
// More comprehensive check for mobile/tablet devices
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
// Check if the device is mobile
useEffect(() => {
const checkIsMobile = () => {
// More comprehensive check for mobile/tablet devices
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
};
// Always show for mobile devices on each visit
const isMobile = checkIsMobile();
setIsVisible(isMobile);
}, []);
// Close the prompt when video plays
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
// Just close the prompt when video plays
setIsVisible(false);
};
video.addEventListener('play', handlePlay);
return () => {
video.removeEventListener('play', handlePlay);
};
}, [videoRef]);
const handlePlayClick = () => {
onPlay();
// Prompt will be closed by the play event handler
};
// Always show for mobile devices on each visit
const isMobile = checkIsMobile();
setIsVisible(isMobile);
}, []);
if (!isVisible) return null;
// Close the prompt when video plays
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlePlay = () => {
// Just close the prompt when video plays
setIsVisible(false);
};
video.addEventListener("play", handlePlay);
return () => {
video.removeEventListener("play", handlePlay);
};
}, [videoRef]);
const handlePlayClick = () => {
onPlay();
// Prompt will be closed by the play event handler
};
if (!isVisible) return null;
return (
<div className="mobile-play-prompt-overlay">
<div className="mobile-play-prompt">
{/* <h3>Mobile Device Notice</h3>
return (
<div className="mobile-play-prompt-overlay">
<div className="mobile-play-prompt">
{/* <h3>Mobile Device Notice</h3>
<p>
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
@ -65,12 +65,12 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
</ol>
</div> */}
<button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing...
</button>
</div>
</div>
);
<button className="mobile-play-button" onClick={handlePlayClick}>
Click to start editing...
</button>
</div>
</div>
);
};
export default MobilePlayPrompt;

View File

@ -1,184 +1,197 @@
import { useEffect, useState, useRef } from "react";
import { formatTime } from "@/lib/timeUtils";
import "../styles/IOSVideoPlayer.css";
import { useEffect, useState, useRef } from 'react';
import { formatTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import '../styles/IOSVideoPlayer.css';
interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
}
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Clean up intervals on unmount
useEffect(() => {
return () => {
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// 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.mp3';
}
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]);
// Function to jump 15 seconds backward
const jumpBackward15 = () => {
if (iosVideoRef) {
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
iosVideoRef.currentTime = newTime;
}
};
}, []);
// Get the video source URL from the main player
useEffect(() => {
if (videoRef.current && videoRef.current.querySelector("source")) {
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
if (source && source.src) {
setVideoUrl(source.src);
}
} else {
// Fallback to sample video if needed
setVideoUrl("/videos/sample-video-10m.mp4");
}
}, [videoRef]);
// Function to jump 15 seconds forward
const jumpForward15 = () => {
if (iosVideoRef) {
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
iosVideoRef.currentTime = newTime;
}
};
// Function to jump 15 seconds backward
const jumpBackward15 = () => {
if (iosVideoRef) {
const newTime = Math.max(0, 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();
// Function to jump 15 seconds forward
const jumpForward15 = () => {
if (iosVideoRef) {
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
iosVideoRef.currentTime = newTime;
}
};
if (!iosVideoRef) return;
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
// 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) {
// First immediate adjustment
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;
}
};
// Setup continuous adjustment
incrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
}
}, 100);
};
// Start continuous 50ms decrement when button is held
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
// Stop continuous increment
const stopIncrement = () => {
if (incrementIntervalRef.current) {
clearInterval(incrementIntervalRef.current);
incrementIntervalRef.current = null;
}
};
if (!iosVideoRef) return;
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// Start continuous 50ms decrement when button is held
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection
e.preventDefault();
// First immediate adjustment
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
if (!iosVideoRef) return;
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// Setup continuous adjustment
decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
// First immediate adjustment
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
}
}, 100);
};
// Stop continuous decrement
const stopDecrement = () => {
if (decrementIntervalRef.current) {
clearInterval(decrementIntervalRef.current);
decrementIntervalRef.current = null;
}
};
// Setup continuous adjustment
decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) {
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
}
}, 100);
};
return (
<div className="ios-video-player-container">
{/* Current Time / Duration Display */}
<div className="ios-time-display mb-2">
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
// Stop continuous decrement
const stopDecrement = () => {
if (decrementIntervalRef.current) {
clearInterval(decrementIntervalRef.current);
decrementIntervalRef.current = null;
}
};
{/* iOS-optimized Video Element with Native Controls */}
<video
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
return (
<div className="ios-video-player-container">
{/* Current Time / Duration Display */}
<div className="ios-time-display mb-2">
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button
onClick={jumpBackward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
-15s
</button>
<button
onClick={jumpForward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
+15s
</button>
</div>
{/* iOS-optimized Video Element with Native Controls */}
<video
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Fine Control Buttons */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
<button
onMouseDown={startDecrement}
onTouchStart={startDecrement}
onMouseUp={stopDecrement}
onMouseLeave={stopDecrement}
onTouchEnd={stopDecrement}
onTouchCancel={stopDecrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
-50ms
</button>
<button
onMouseDown={startIncrement}
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
onMouseLeave={stopIncrement}
onTouchEnd={stopIncrement}
onTouchCancel={stopIncrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
+50ms
</button>
</div>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button
onClick={jumpBackward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
-15s
</button>
<button
onClick={jumpForward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
>
+15s
</button>
</div>
<div className="ios-note mt-2 text-xs text-gray-500">
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
</div>
</div>
);
{/* iOS Fine Control Buttons */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
<button
onMouseDown={startDecrement}
onTouchStart={startDecrement}
onMouseUp={stopDecrement}
onMouseLeave={stopDecrement}
onTouchEnd={stopDecrement}
onTouchCancel={stopDecrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
-50ms
</button>
<button
onMouseDown={startIncrement}
onTouchStart={startIncrement}
onMouseUp={stopIncrement}
onMouseLeave={stopIncrement}
onTouchEnd={stopIncrement}
onTouchCancel={stopIncrement}
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
>
+50ms
</button>
</div>
<div className="ios-note mt-2 text-xs text-gray-500">
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
</div>
</div>
);
};
export default IOSVideoPlayer;

View File

@ -1,74 +1,74 @@
import React, { useEffect } from "react";
import "../styles/Modal.css";
import React, { useEffect } from 'react';
import '../styles/Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
// Close modal when Escape key is pressed
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
onClose();
}
// 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();
}
};
document.addEventListener("keydown", handleEscapeKey);
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = "hidden";
}
<div className="modal-content">{children}</div>
return () => {
document.removeEventListener("keydown", handleEscapeKey);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Handle click outside the modal content to close it
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{actions && <div className="modal-actions">{actions}</div>}
</div>
</div>
<div className="modal-content">{children}</div>
{actions && <div className="modal-actions">{actions}</div>}
</div>
</div>
);
);
};
export default Modal;

View File

@ -1,452 +1,479 @@
import React, { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
import logger from "../lib/logger";
import "../styles/VideoPlayer.css";
import React, { useRef, useEffect, useState } from 'react';
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
import logger from '../lib/logger';
import '../styles/VideoPlayer.css';
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
isPlaying: boolean;
isMuted?: boolean;
onPlayPause: () => void;
onSeek: (time: number) => void;
onToggleMute?: () => void;
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
isPlaying: boolean;
isMuted?: boolean;
onPlayPause: () => void;
onSeek: (time: number) => void;
onToggleMute?: () => void;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
videoRef,
currentTime,
duration,
isPlaying,
isMuted = false,
onPlayPause,
onSeek,
onToggleMute
videoRef,
currentTime,
duration,
isPlaying,
isMuted = false,
onPlayPause,
onSeek,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [lastPosition, setLastPosition] = useState<number | null>(null);
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
const [tooltipTime, setTooltipTime] = useState(0);
const progressRef = useRef<HTMLDivElement>(null);
const [isIOS, setIsIOS] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [lastPosition, setLastPosition] = useState<number | null>(null);
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({
x: 0,
});
const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl =
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
"/videos/sample-video-10m.mp4";
const sampleVideoUrl =
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp3';
// Detect iOS device
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
// Check if the media is an audio file
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
setIsIOS(checkIOS());
// Detect iOS device
useEffect(() => {
const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
};
// Check if video was previously initialized
if (typeof window !== "undefined") {
const wasInitialized = localStorage.getItem("video_initialized") === "true";
setHasInitialized(wasInitialized);
}
}, []);
setIsIOS(checkIOS());
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(true);
if (typeof window !== "undefined") {
localStorage.setItem("video_initialized", "true");
}
}
}, [isPlaying, hasInitialized]);
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
video.setAttribute("x-webkit-airplay", "allow");
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== "undefined") {
window.lastSeekedPosition = video.currentTime;
// Check if video was previously initialized
if (typeof window !== 'undefined') {
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
setHasInitialized(wasInitialized);
}
}
}, []);
// Update initialized state when video plays
useEffect(() => {
if (isPlaying && !hasInitialized) {
setHasInitialized(true);
if (typeof window !== 'undefined') {
localStorage.setItem('video_initialized', 'true');
}
}
}, [isPlaying, hasInitialized]);
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
video.setAttribute('x-webkit-airplay', 'allow');
// Store the last known good position for iOS
const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime);
if (typeof window !== 'undefined') {
window.lastSeekedPosition = video.currentTime;
}
}
};
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug('Video play event fired');
if (isIOS) {
setHasInitialized(true);
localStorage.setItem('video_initialized', 'true');
}
};
const handlePause = () => {
logger.debug('Video pause event fired');
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking)
useEffect(() => {
setLastPosition(currentTime);
}, [currentTime]);
// Jump 10 seconds forward
const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration);
onSeek(newTime);
setLastPosition(newTime);
};
// Handle iOS-specific play/pause state
const handlePlay = () => {
logger.debug("Video play event fired");
if (isIOS) {
setHasInitialized(true);
localStorage.setItem("video_initialized", "true");
}
// Jump 10 seconds backward
const handleBackward = () => {
const newTime = Math.max(currentTime - 10, 0);
onSeek(newTime);
setLastPosition(newTime);
};
const handlePause = () => {
logger.debug("Video pause event fired");
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle start of progress bar dragging
const handleProgressDragStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position
handleProgressDrag(e);
// Set up document-level event listeners for mouse movement and release
const handleMouseMove = (moveEvent: MouseEvent) => {
if (isDraggingProgressRef.current) {
handleProgressDrag(moveEvent);
}
};
const handleMouseUp = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
// Handle progress dragging for both mouse and touch events
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
if (!progressRef.current) return;
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
};
}, [videoRef, isIOS, isDraggingProgressRef]);
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition;
// Save current time to lastPosition when it changes (from external seeking)
useEffect(() => {
setLastPosition(currentTime);
}, [currentTime]);
// Update tooltip position and time
setTooltipPosition({
x: e.clientX,
});
setTooltipTime(seekTime);
// Jump 10 seconds forward
const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration);
onSeek(newTime);
setLastPosition(newTime);
};
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Jump 10 seconds backward
const handleBackward = () => {
const newTime = Math.max(currentTime - 10, 0);
onSeek(newTime);
setLastPosition(newTime);
};
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle start of progress bar dragging
const handleProgressDragStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position
handleProgressDrag(e);
// Set up document-level event listeners for mouse movement and release
const handleMouseMove = (moveEvent: MouseEvent) => {
if (isDraggingProgressRef.current) {
handleProgressDrag(moveEvent);
}
onSeek(seekTime);
};
const handleMouseUp = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
// Handle touch events for progress bar
const handleProgressTouchStart = (e: React.TouchEvent) => {
if (!progressRef.current || !e.touches[0]) return;
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position using touch
handleProgressTouchMove(e);
// Set up document-level event listeners for touch movement and release
const handleTouchMove = (moveEvent: TouchEvent) => {
if (isDraggingProgressRef.current) {
handleProgressTouchMove(moveEvent);
}
};
const handleTouchEnd = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// Handle touch dragging on progress bar
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
if (!progressRef.current) return;
// Handle progress dragging for both mouse and touch events
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
if (!progressRef.current) return;
// Get the touch coordinates
const touch = 'touches' in e ? e.touches[0] : null;
if (!touch) return;
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition;
e.preventDefault(); // Prevent scrolling while dragging
// Update tooltip position and time
setTooltipPosition({ x: e.clientX });
setTooltipTime(seekTime);
const rect = progressRef.current.getBoundingClientRect();
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
const seekTime = duration * touchPosition;
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Update tooltip position and time
setTooltipPosition({
x: touch.clientX,
});
setTooltipTime(seekTime);
// Also store globally for integration with other components
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
// Store position for iOS Safari
setLastPosition(seekTime);
onSeek(seekTime);
};
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
// Handle touch events for progress bar
const handleProgressTouchStart = (e: React.TouchEvent) => {
if (!progressRef.current || !e.touches[0]) return;
e.preventDefault();
setIsDraggingProgress(true);
isDraggingProgressRef.current = true;
// Get initial position using touch
handleProgressTouchMove(e);
// Set up document-level event listeners for touch movement and release
const handleTouchMove = (moveEvent: TouchEvent) => {
if (isDraggingProgressRef.current) {
handleProgressTouchMove(moveEvent);
}
onSeek(seekTime);
};
const handleTouchEnd = () => {
setIsDraggingProgress(false);
isDraggingProgressRef.current = false;
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener("touchcancel", handleTouchEnd);
// Handle click on progress bar (for non-drag interactions)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we're already dragging, don't handle the click
if (isDraggingProgress) return;
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
const seekTime = duration * clickPosition;
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== 'undefined') {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
};
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
document.addEventListener("touchcancel", handleTouchEnd);
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
// Handle touch dragging on progress bar
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
if (!progressRef.current) return;
// Handle click on video to play/pause
const handleVideoClick = () => {
const video = videoRef.current;
if (!video) return;
// Get the touch coordinates
const touch = "touches" in e ? e.touches[0] : null;
if (!touch) return;
// If the video is paused, we want to play it
if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) {
logger.debug('iOS: Explicitly setting position before play:', lastPosition);
e.preventDefault(); // Prevent scrolling while dragging
// First, seek to the position
video.currentTime = lastPosition;
const rect = progressRef.current.getBoundingClientRect();
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
const seekTime = duration * touchPosition;
// Use a small timeout to ensure seeking is complete before play
setTimeout(() => {
if (videoRef.current) {
// Try to play with proper promise handling
videoRef.current
.play()
.then(() => {
logger.debug(
'iOS: Play started successfully at position:',
videoRef.current?.currentTime
);
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error('iOS: Error playing video:', err);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video
.play()
.then(() => {
logger.debug('Normal: Play started successfully');
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error('Error playing video:', err);
});
}
} else {
// If playing, pause and update state
video.pause();
onPlayPause();
}
};
// Update tooltip position and time
setTooltipPosition({ x: touch.clientX });
setTooltipTime(seekTime);
// Store position for iOS Safari
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
};
// Handle click on progress bar (for non-drag interactions)
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we're already dragging, don't handle the click
if (isDraggingProgress) return;
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
const seekTime = duration * clickPosition;
// Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime);
// Also store globally for integration with other components
if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime;
}
onSeek(seekTime);
}
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
// Handle click on video to play/pause
const handleVideoClick = () => {
const video = videoRef.current;
if (!video) return;
// If the video is paused, we want to play it
if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) {
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
// First, seek to the position
video.currentTime = lastPosition;
// Use a small timeout to ensure seeking is complete before play
setTimeout(() => {
if (videoRef.current) {
// Try to play with proper promise handling
videoRef.current
.play()
.then(() => {
logger.debug(
"iOS: Play started successfully at position:",
videoRef.current?.currentTime
);
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error("iOS: Error playing video:", err);
});
}
}, 50);
} else {
// Normal play (non-iOS or no remembered position)
video
.play()
.then(() => {
logger.debug("Normal: Play started successfully");
onPlayPause(); // Update parent state after successful play
})
.catch((err) => {
console.error("Error playing video:", err);
});
}
} else {
// If playing, pause and update state
video.pause();
onPlayPause();
}
};
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="auto"
crossOrigin="anonymous"
onClick={handleVideoClick}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
>
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
{isIOS && !hasInitialized && !isPlaying && (
<div className="ios-first-play-indicator">
<div className="ios-play-message">Tap Play to initialize video controls</div>
</div>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
onClick={handleProgressClick}
onMouseDown={handleProgressDragStart}
onTouchStart={handleProgressTouchStart}
>
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<div
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: "translateX(-50%)"
}}
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="auto"
crossOrigin="anonymous"
onClick={handleVideoClick}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
poster={posterImage}
>
{formatDetailedTime(tooltipTime)}
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
{isIOS && !hasInitialized && !isPlaying && (
<div className="ios-first-play-indicator">
<div className="ios-play-message">Tap Play to initialize video controls</div>
</div>
)}
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar with enhanced dragging */}
<div
ref={progressRef}
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`}
onClick={handleProgressClick}
onMouseDown={handleProgressDragStart}
onTouchStart={handleProgressTouchStart}
>
<div
className="video-progress-fill"
style={{
width: `${progressPercentage}%`,
}}
></div>
<div
className="video-scrubber"
style={{
left: `${progressPercentage}%`,
}}
></div>
{/* Floating time tooltip when dragging */}
{isDraggingProgress && (
<div
className="video-time-tooltip"
style={{
left: `${tooltipPosition.x}px`,
transform: 'translateX(-50%)',
}}
>
{formatDetailedTime(tooltipTime)}
</div>
)}
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? 'Unmute' : 'Mute'}
onClick={onToggleMute}
data-tooltip={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
)}
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"}
onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
);
);
};
export default VideoPlayer;

View File

@ -3,29 +3,29 @@
* but always shows errors, warnings, and info messages.
*/
const logger = {
/**
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === "development") {
console.debug(...args);
}
},
/**
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(...args);
}
},
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args)
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args),
};
export default logger;

View File

@ -1,55 +1,51 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
import { QueryClient, QueryFunction } from '@tanstack/react-query';
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include"
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include"
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: 'include',
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
return res;
}
type UnauthorizedBehavior = 'returnNull' | 'throw';
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: 'include',
});
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: 'throw' }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
mutations: {
retry: false
}
}
});

View File

@ -2,33 +2,33 @@
* Format seconds to HH:MM:SS.mmm format with millisecond precision
*/
export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000";
if (isNaN(seconds)) return '00:00:00.000';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
const formattedHours = String(hours).padStart(2, '0');
const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
};
/**
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
*/
export const formatTime = (seconds: number): string => {
// Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds);
// Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds);
};
/**
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
*/
export const formatLongTime = (seconds: number): string => {
// Use the detailed format
return formatDetailedTime(seconds);
// Use the detailed format
return formatDetailedTime(seconds);
};

View File

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

View File

@ -3,45 +3,42 @@
* Returns a CSS color based on the segment position
*/
export const generateSolidColor = (time: number, duration: number): string => {
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
/**
* Legacy function kept for compatibility
* Now returns a data URL for a solid color square instead of a video thumbnail
*/
export const generateThumbnail = async (
videoElement: HTMLVideoElement,
time: number
): Promise<string> => {
return new Promise((resolve) => {
// Create a small canvas for the solid color
const canvas = document.createElement("canvas");
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
export const generateThumbnail = async (videoElement: HTMLVideoElement, time: number): Promise<string> => {
return new Promise((resolve) => {
// Create a small canvas for the solid color
const canvas = document.createElement('canvas');
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
const ctx = canvas.getContext("2d");
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
const ctx = canvas.getContext('2d');
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
// Fill with solid color
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Fill with solid color
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL("image/png", 0.5);
resolve(dataUrl);
});
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL('image/png', 0.5);
resolve(dataUrl);
});
};

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