Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0cb977f2 | ||
|
|
a5e6e7b9ca | ||
|
|
b39072c8ae | ||
|
|
f4ab60e894 | ||
|
|
8656b40c5b | ||
|
|
553a25a86f | ||
|
|
1c1af489f1 | ||
|
|
c4c5ecf06a | ||
|
|
725cc71960 | ||
|
|
0c1c5bbb09 |
6
.gitignore
vendored
@ -5,6 +5,7 @@ media_files/original/
|
|||||||
media_files/hls/
|
media_files/hls/
|
||||||
media_files/chunks/
|
media_files/chunks/
|
||||||
media_files/uploads/
|
media_files/uploads/
|
||||||
|
media_files/tinymce_media/
|
||||||
postgres_data/
|
postgres_data/
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
logs/
|
logs/
|
||||||
@ -29,3 +30,8 @@ static/video_editor/videos/sample-video-37s.mp4
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
static/video_editor/videos/sample-video-10m.mp4
|
static/video_editor/videos/sample-video-10m.mp4
|
||||||
static/video_editor/videos/sample-video-10s.mp4
|
static/video_editor/videos/sample-video-10s.mp4
|
||||||
|
frontend-tools/video-js/public/videos/sample-video-white.mp4
|
||||||
|
frontend-tools/video-editor/client/public/videos/sample-video.mp3
|
||||||
|
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
||||||
|
static/chapters_editor/videos/sample-video.mp3
|
||||||
|
static/video_editor/videos/sample-video.mp3
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
*
|
/templates/cms/*
|
||||||
|
/templates/*.html
|
||||||
|
*.scss
|
||||||
21
.prettierrc
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -105,6 +105,23 @@ USE_L10N = True
|
|||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
|
# these are the portal logos (dark and light)
|
||||||
|
# set new paths for svg or png if you want to override
|
||||||
|
# svg has priority over png, so if you want to use
|
||||||
|
# custom pngs and not svgs, remove the lines with svgs
|
||||||
|
# or set as empty strings
|
||||||
|
# example:
|
||||||
|
# PORTAL_LOGO_DARK_SVG = ""
|
||||||
|
# PORTAL_LOGO_LIGHT_SVG = ""
|
||||||
|
# place the files on static/images folder
|
||||||
|
PORTAL_LOGO_DARK_SVG = "/static/images/logo_dark.svg"
|
||||||
|
PORTAL_LOGO_DARK_PNG = "/static/images/logo_dark.png"
|
||||||
|
PORTAL_LOGO_LIGHT_SVG = "/static/images/logo_light.svg"
|
||||||
|
PORTAL_LOGO_LIGHT_PNG = "/static/images/logo_dark.png"
|
||||||
|
|
||||||
|
# paths to extra css files to be included, eg "/static/css/custom.css"
|
||||||
|
# place css inside static/css folder
|
||||||
|
EXTRA_CSS_PATHS = []
|
||||||
# protection agains anonymous users
|
# protection agains anonymous users
|
||||||
# per ip address limit, for actions as like/dislike/report
|
# per ip address limit, for actions as like/dislike/report
|
||||||
TIME_TO_ACTION_ANONYMOUS = 10 * 60
|
TIME_TO_ACTION_ANONYMOUS = 10 * 60
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
VERSION = "6.7.0"
|
VERSION = "7.0.1-beta.8"
|
||||||
|
|||||||
@ -30,7 +30,8 @@ fi
|
|||||||
|
|
||||||
# We should do this only for folders that have a different owner, since it is an expensive operation
|
# We should do this only for folders that have a different owner, since it is an expensive operation
|
||||||
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
|
# Also ignoring .git folder to fix this issue https://github.com/mediacms-io/mediacms/issues/934
|
||||||
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
|
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# This script builds the video editor package and deploys the frontend assets to the static directory.
|
# This script builds the video editor package and deploys the frontend assets to the static directory.
|
||||||
|
# How to run: sh deploy/scripts/build_and_deploy.sh
|
||||||
|
|
||||||
# Exit on any error
|
# Exit on any error
|
||||||
set -e
|
set -e
|
||||||
@ -12,9 +13,21 @@ cd frontend-tools/video-editor
|
|||||||
yarn build:django
|
yarn build:django
|
||||||
cd ../../
|
cd ../../
|
||||||
|
|
||||||
|
# Build chapter editor package
|
||||||
|
echo "Building chapters editor package..."
|
||||||
|
cd frontend-tools/chapters-editor
|
||||||
|
yarn build:django
|
||||||
|
cd ../../
|
||||||
|
|
||||||
|
# Build video js package
|
||||||
|
echo "Building video js package..."
|
||||||
|
cd frontend-tools/video-js
|
||||||
|
yarn build:django
|
||||||
|
cd ../../
|
||||||
|
|
||||||
# Run npm build in the frontend container
|
# Run npm build in the frontend container
|
||||||
echo "Building frontend assets..."
|
echo "Building frontend assets..."
|
||||||
docker compose -f docker-compose-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
|
# Copy static assets to the static directory
|
||||||
echo "Copying static assets..."
|
echo "Copying static assets..."
|
||||||
@ -22,6 +35,6 @@ cp -r frontend/dist/static/* static/
|
|||||||
|
|
||||||
# Restart the web service
|
# Restart the web service
|
||||||
echo "Restarting web service..."
|
echo "Restarting web service..."
|
||||||
docker compose -f docker-compose-dev.yaml restart web
|
docker compose -f docker-compose/docker-compose-dev-updated.yaml restart web
|
||||||
|
|
||||||
echo "Build and deployment completed successfully!"
|
echo "Build and deployment completed successfully!"
|
||||||
@ -33,55 +33,35 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
|
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
|
||||||
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
|
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
|
||||||
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
|
|
||||||
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
|
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
|
||||||
- npm_global:/home/node/.npm-global
|
- npm_cache:/home/node/.npm
|
||||||
working_dir: /home/mediacms.io/mediacms/frontend/
|
working_dir: /home/mediacms.io/mediacms/frontend/
|
||||||
command: >
|
command: >
|
||||||
bash -c "
|
bash -c "
|
||||||
echo 'Setting up npm global directory...' &&
|
echo 'Checking dependencies...' &&
|
||||||
mkdir -p /home/node/.npm-global &&
|
if [ ! -f node_modules/.install-complete ]; then
|
||||||
chown -R node:node /home/node/.npm-global &&
|
echo 'First-time setup or dependencies changed, installing...' &&
|
||||||
echo 'Setting up permissions...' &&
|
npm install --legacy-peer-deps --cache /home/node/.npm &&
|
||||||
chown -R node:node /home/mediacms.io/mediacms/frontend &&
|
|
||||||
echo 'Cleaning up node_modules...' &&
|
|
||||||
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
|
||||||
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
|
||||||
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
|
||||||
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
|
|
||||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
|
|
||||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
|
|
||||||
echo 'Switching to node user...' &&
|
|
||||||
su node -c '
|
|
||||||
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
|
|
||||||
echo \"Setting up frontend...\" &&
|
|
||||||
rm -f package-lock.json &&
|
|
||||||
rm -f packages/player/package-lock.json &&
|
|
||||||
rm -f packages/scripts/package-lock.json &&
|
|
||||||
echo \"Installing dependencies...\" &&
|
|
||||||
npm install --legacy-peer-deps &&
|
|
||||||
echo \"Setting up workspaces...\" &&
|
|
||||||
npm install -g npm@latest &&
|
|
||||||
cd packages/scripts &&
|
cd packages/scripts &&
|
||||||
npm install --legacy-peer-deps &&
|
npm install --legacy-peer-deps --cache /home/node/.npm &&
|
||||||
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
|
|
||||||
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
|
|
||||||
npm install tslib@2.6.2 --save --legacy-peer-deps &&
|
|
||||||
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
|
|
||||||
npm install --legacy-peer-deps &&
|
|
||||||
npm run build &&
|
npm run build &&
|
||||||
cd ../.. &&
|
cd ../.. &&
|
||||||
cd packages/player &&
|
touch node_modules/.install-complete &&
|
||||||
npm install --legacy-peer-deps &&
|
echo 'Dependencies installed successfully'
|
||||||
npm run build &&
|
else
|
||||||
cd ../.. &&
|
echo 'Dependencies already installed, skipping installation...' &&
|
||||||
echo \"Starting development server...\" &&
|
if [ ! -d packages/scripts/dist ]; then
|
||||||
npm run start
|
echo 'Building scripts package...' &&
|
||||||
'"
|
cd packages/scripts &&
|
||||||
|
npm run build &&
|
||||||
|
cd ../..
|
||||||
|
fi
|
||||||
|
fi &&
|
||||||
|
echo 'Starting development server...' &&
|
||||||
|
npm run start
|
||||||
|
"
|
||||||
env_file:
|
env_file:
|
||||||
- ${PWD}/frontend/.env
|
- ${PWD}/frontend/.env
|
||||||
environment:
|
|
||||||
- NPM_CONFIG_PREFIX=/home/node/.npm-global
|
|
||||||
ports:
|
ports:
|
||||||
- "8088:8088"
|
- "8088:8088"
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -140,6 +120,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
player_node_modules:
|
|
||||||
scripts_node_modules:
|
scripts_node_modules:
|
||||||
npm_global:
|
npm_cache:
|
||||||
|
|||||||
@ -240,7 +240,12 @@ Docker Compose installation: edit `deploy/docker/local_settings.py`, make a chan
|
|||||||
|
|
||||||
### 5.1 Change portal logo
|
### 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
|
### 5.2 Set global portal title
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,12 @@ def stuff(request):
|
|||||||
ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/')
|
ret["FRONTEND_HOST"] = request.build_absolute_uri('/').rstrip('/')
|
||||||
ret["DEFAULT_THEME"] = settings.DEFAULT_THEME
|
ret["DEFAULT_THEME"] = settings.DEFAULT_THEME
|
||||||
ret["PORTAL_NAME"] = settings.PORTAL_NAME
|
ret["PORTAL_NAME"] = settings.PORTAL_NAME
|
||||||
|
|
||||||
|
ret["PORTAL_LOGO_DARK_SVG"] = getattr(settings, 'PORTAL_LOGO_DARK_SVG', "")
|
||||||
|
ret["PORTAL_LOGO_DARK_PNG"] = getattr(settings, 'PORTAL_LOGO_DARK_PNG', "")
|
||||||
|
ret["PORTAL_LOGO_LIGHT_SVG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_SVG', "")
|
||||||
|
ret["PORTAL_LOGO_LIGHT_PNG"] = getattr(settings, 'PORTAL_LOGO_LIGHT_PNG', "")
|
||||||
|
ret["EXTRA_CSS_PATHS"] = getattr(settings, 'EXTRA_CSS_PATHS', [])
|
||||||
ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION
|
ret["PORTAL_DESCRIPTION"] = settings.PORTAL_DESCRIPTION
|
||||||
ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN
|
ret["LOAD_FROM_CDN"] = settings.LOAD_FROM_CDN
|
||||||
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED
|
ret["CAN_LOGIN"] = settings.LOGIN_ALLOWED
|
||||||
|
|||||||
@ -604,7 +604,7 @@ def handle_video_chapters(media, chapters):
|
|||||||
else:
|
else:
|
||||||
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
|
||||||
|
|
||||||
return media.chapter_data
|
return {'chapters': media.chapter_data}
|
||||||
|
|
||||||
|
|
||||||
def change_media_owner(media_id, new_user):
|
def change_media_owner(media_id, new_user):
|
||||||
|
|||||||
@ -630,7 +630,7 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def trim_video_url(self):
|
def trim_video_url(self):
|
||||||
if self.media_type not in ["video"]:
|
if self.media_type not in ["video", "audio"]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||||
@ -642,7 +642,7 @@ class Media(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def trim_video_path(self):
|
def trim_video_path(self):
|
||||||
if self.media_type not in ["video"]:
|
if self.media_type not in ["video", "audio"]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
|
||||||
|
|||||||
@ -12,40 +12,19 @@ class VideoChapterData(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['media']
|
unique_together = ['media']
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
from .. import tasks
|
|
||||||
|
|
||||||
is_new = self.pk is None
|
|
||||||
if is_new or (not is_new and self._check_data_changed()):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
tasks.produce_video_chapters.delay(self.pk)
|
|
||||||
else:
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def _check_data_changed(self):
|
|
||||||
if self.pk:
|
|
||||||
old_instance = VideoChapterData.objects.get(pk=self.pk)
|
|
||||||
return old_instance.data != self.data
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chapter_data(self):
|
def chapter_data(self):
|
||||||
# ensure response is consistent
|
# ensure response is consistent
|
||||||
data = []
|
data = []
|
||||||
for item in self.data:
|
if self.data and isinstance(self.data, list):
|
||||||
if item.get("start") and item.get("title"):
|
for item in self.data:
|
||||||
thumbnail = item.get("thumbnail")
|
if item.get("startTime") and item.get("endTime") and item.get("chapterTitle"):
|
||||||
if thumbnail:
|
chapter_item = {
|
||||||
thumbnail = helpers.url_from_path(thumbnail)
|
'startTime': item.get("startTime"),
|
||||||
else:
|
'endTime': item.get("endTime"),
|
||||||
thumbnail = "static/images/chapter_default.jpg"
|
'chapterTitle': item.get("chapterTitle"),
|
||||||
data.append(
|
|
||||||
{
|
|
||||||
"start": item.get("start"),
|
|
||||||
"title": item.get("title"),
|
|
||||||
"thumbnail": thumbnail,
|
|
||||||
}
|
}
|
||||||
)
|
data.append(chapter_item)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,6 @@ from .models import (
|
|||||||
Subtitle,
|
Subtitle,
|
||||||
Tag,
|
Tag,
|
||||||
TranscriptionRequest,
|
TranscriptionRequest,
|
||||||
VideoChapterData,
|
|
||||||
VideoTrimRequest,
|
VideoTrimRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -950,45 +949,6 @@ def update_encoding_size(encoding_id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@task(name="produce_video_chapters", queue="short_tasks")
|
|
||||||
def produce_video_chapters(chapter_id):
|
|
||||||
# this is not used
|
|
||||||
return False
|
|
||||||
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
|
|
||||||
if not chapter_object:
|
|
||||||
return False
|
|
||||||
|
|
||||||
media = chapter_object.media
|
|
||||||
video_path = media.media_file.path
|
|
||||||
output_folder = media.video_chapters_folder
|
|
||||||
|
|
||||||
chapters = chapter_object.data
|
|
||||||
|
|
||||||
width = 336
|
|
||||||
height = 188
|
|
||||||
|
|
||||||
if not os.path.exists(output_folder):
|
|
||||||
os.makedirs(output_folder)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for i, chapter in enumerate(chapters):
|
|
||||||
timestamp = chapter["start"]
|
|
||||||
title = chapter["title"]
|
|
||||||
|
|
||||||
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
|
|
||||||
output_path = os.path.join(output_folder, output_filename)
|
|
||||||
|
|
||||||
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
|
|
||||||
ret = run_command(command) # noqa
|
|
||||||
if os.path.exists(output_path) and get_file_type(output_path) == "image":
|
|
||||||
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
|
|
||||||
|
|
||||||
chapter_object.data = results
|
|
||||||
chapter_object.save(update_fields=["data"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
|
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
|
||||||
def post_trim_action(friendly_token):
|
def post_trim_action(friendly_token):
|
||||||
"""Perform post-processing actions after video trimming
|
"""Perform post-processing actions after video trimming
|
||||||
|
|||||||
@ -115,7 +115,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
|
|
||||||
if settings.USERS_NEEDS_TO_BE_APPROVED:
|
if settings.USERS_NEEDS_TO_BE_APPROVED:
|
||||||
urlpatterns.append(re_path(r"^approval_required", views.approval_required, name="approval_required"))
|
urlpatterns.append(re_path(r"^approval_required/", views.approval_required, name="approval_required"))
|
||||||
|
|
||||||
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
|
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
|
||||||
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
|
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
|
||||||
|
|||||||
@ -244,8 +244,6 @@ def history(request):
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@login_required
|
@login_required
|
||||||
def video_chapters(request, friendly_token):
|
def video_chapters(request, friendly_token):
|
||||||
# this is not ready...
|
|
||||||
return False
|
|
||||||
if not request.method == "POST":
|
if not request.method == "POST":
|
||||||
return HttpResponseRedirect("/")
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
@ -258,20 +256,26 @@ def video_chapters(request, friendly_token):
|
|||||||
return HttpResponseRedirect("/")
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
try:
|
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 = []
|
chapters = []
|
||||||
for _, chapter_data in enumerate(data):
|
for _, chapter_data in enumerate(data):
|
||||||
start_time = chapter_data.get('start')
|
start_time = chapter_data.get('startTime')
|
||||||
title = chapter_data.get('title')
|
end_time = chapter_data.get('endTime')
|
||||||
if start_time and title:
|
chapter_title = chapter_data.get('chapterTitle')
|
||||||
|
if start_time and end_time and chapter_title:
|
||||||
chapters.append(
|
chapters.append(
|
||||||
{
|
{
|
||||||
'start': start_time,
|
'startTime': start_time,
|
||||||
'title': title,
|
'endTime': end_time,
|
||||||
|
'chapterTitle': chapter_title,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa
|
except Exception as e: # noqa
|
||||||
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with 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)
|
ret = handle_video_chapters(media, chapters)
|
||||||
|
|
||||||
@ -358,8 +362,6 @@ def publish_media(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def edit_chapters(request):
|
def edit_chapters(request):
|
||||||
"""Edit chapters"""
|
"""Edit chapters"""
|
||||||
# not implemented yet
|
|
||||||
return False
|
|
||||||
friendly_token = request.GET.get("m", "").strip()
|
friendly_token = request.GET.get("m", "").strip()
|
||||||
if not friendly_token:
|
if not friendly_token:
|
||||||
return HttpResponseRedirect("/")
|
return HttpResponseRedirect("/")
|
||||||
@ -371,10 +373,11 @@ def edit_chapters(request):
|
|||||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||||
return HttpResponseRedirect("/")
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
|
chapters = media.chapter_data
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"cms/edit_chapters.html",
|
"cms/edit_chapters.html",
|
||||||
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token},
|
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token, "chapters": chapters},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -426,7 +429,7 @@ def edit_video(request):
|
|||||||
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
if not (request.user == media.user or is_mediacms_editor(request.user)):
|
||||||
return HttpResponseRedirect("/")
|
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")
|
messages.add_message(request, messages.INFO, "Media is not video")
|
||||||
return HttpResponseRedirect(media.get_absolute_url())
|
return HttpResponseRedirect(media.get_absolute_url())
|
||||||
|
|
||||||
|
|||||||
15
frontend-tools/chapters-editor/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
server/public
|
||||||
|
vite.config.ts.*
|
||||||
|
*.tar.gz
|
||||||
|
yt.readme.md
|
||||||
|
client/public/videos/sample-video.mp4
|
||||||
|
client/public/videos/sample-video-30s.mp4
|
||||||
|
client/public/videos/sample-video-37s.mp4
|
||||||
|
videos/sample-video-37s.mp4
|
||||||
|
client/public/videos/sample-video-30s.mp4
|
||||||
|
client/public/videos/sample-video-1.mp4
|
||||||
|
client/public/videos/sample-video-10m.mp4
|
||||||
|
client/public/videos/sample-video-10s.mp4
|
||||||
0
frontend-tools/chapters-editor/.prettierignore
Normal file
5
frontend-tools/chapters-editor/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"prettier.configPath": ".prettierrc"
|
||||||
|
}
|
||||||
255
frontend-tools/chapters-editor/README.md
Normal 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.
|
||||||
34
frontend-tools/chapters-editor/client/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>Chapters Editor</title>
|
||||||
|
<!-- Add meta tag to help iOS devices render as desktop -->
|
||||||
|
<script>
|
||||||
|
// Try to detect iOS
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
// Replace viewport meta tag with one optimized for desktop view
|
||||||
|
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
||||||
|
if (viewportMeta) {
|
||||||
|
viewportMeta.setAttribute(
|
||||||
|
'content',
|
||||||
|
'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a class to the HTML element for iOS-specific styles
|
||||||
|
document.documentElement.classList.add('ios-device');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="chapters-editor-root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend-tools/chapters-editor/client/public/audio-poster.jpg
Normal file
|
After Width: | Height: | Size: 695 KiB |
186
frontend-tools/chapters-editor/client/src/App.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
|
|
||||||
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 832 B |
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 813 B |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||||
|
.st0{fill:#333333;}
|
||||||
|
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||||
|
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 818 B |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||||
|
.st0{fill:#333333;}
|
||||||
|
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||||
|
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 597 B |
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||||
|
.st0{fill:#333333;}
|
||||||
|
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||||
|
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 611 B |
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title/>
|
||||||
|
<g data-name="1" id="_1">
|
||||||
|
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||||
|
<g transform="translate(2, 0)">
|
||||||
|
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 439 B |
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title/>
|
||||||
|
<g data-name="1" id="_1">
|
||||||
|
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||||
|
<g transform="translate(2, 0)">
|
||||||
|
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 439 B |
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g data-name="1" id="_1">
|
||||||
|
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||||
|
<g transform="translate(30, 0) scale(-1, 1)">
|
||||||
|
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 412 B |
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g data-name="1" id="_1">
|
||||||
|
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||||
|
<g transform="translate(28, 0) scale(-1, 1)">
|
||||||
|
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@ -0,0 +1,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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
1142
frontend-tools/chapters-editor/client/src/hooks/useVideoChapters.tsx
Normal file
796
frontend-tools/chapters-editor/client/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend-tools/chapters-editor/client/src/lib/logger.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* A consistent logger utility that only outputs debug messages in development
|
||||||
|
* but always shows errors, warnings, and info messages.
|
||||||
|
*/
|
||||||
|
const logger = {
|
||||||
|
/**
|
||||||
|
* Logs debug messages only in development environment
|
||||||
|
*/
|
||||||
|
debug: (...args: any[]) => {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.debug(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always logs error messages
|
||||||
|
*/
|
||||||
|
error: (...args: any[]) => console.error(...args),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always logs warning messages
|
||||||
|
*/
|
||||||
|
warn: (...args: any[]) => console.warn(...args),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always logs info messages
|
||||||
|
*/
|
||||||
|
info: (...args: any[]) => console.info(...args),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
51
frontend-tools/chapters-editor/client/src/lib/queryClient.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { QueryClient, QueryFunction } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
async function throwIfResNotOk(res: Response) {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = (await res.text()) || res.statusText;
|
||||||
|
throw new Error(`${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: data ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
await throwIfResNotOk(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnauthorizedBehavior = 'returnNull' | 'throw';
|
||||||
|
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||||
|
({ on401: unauthorizedBehavior }) =>
|
||||||
|
async ({ queryKey }) => {
|
||||||
|
const res = await fetch(queryKey[0] as string, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unauthorizedBehavior === 'returnNull' && res.status === 401) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await throwIfResNotOk(res);
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
queryFn: getQueryFn({ on401: 'throw' }),
|
||||||
|
refetchInterval: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
34
frontend-tools/chapters-editor/client/src/lib/timeUtils.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||||
|
*/
|
||||||
|
export const formatDetailedTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds)) return '00:00:00.000';
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||||
|
|
||||||
|
const formattedHours = String(hours).padStart(2, '0');
|
||||||
|
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||||
|
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
|
||||||
|
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
|
||||||
|
|
||||||
|
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||||
|
*/
|
||||||
|
export const formatTime = (seconds: number): string => {
|
||||||
|
// Use the detailed format instead of the old MM:SS format
|
||||||
|
return formatDetailedTime(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||||
|
*/
|
||||||
|
export const formatLongTime = (seconds: number): string => {
|
||||||
|
// Use the detailed format
|
||||||
|
return formatDetailedTime(seconds);
|
||||||
|
};
|
||||||
6
frontend-tools/chapters-editor/client/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
17
frontend-tools/chapters-editor/client/src/lib/videoUtils.ts
Normal 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}%)`;
|
||||||
|
};
|
||||||
39
frontend-tools/chapters-editor/client/src/main.tsx
Normal 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();
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,397 @@
|
|||||||
|
#chapters-editor-root {
|
||||||
|
/* Tooltip styles - only on desktop where hover is available */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:before {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
visibility 0.2s;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
visibility 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover:before,
|
||||||
|
[data-tooltip]:hover:after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide button tooltips on touch devices */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
[data-tooltip]:before,
|
||||||
|
[data-tooltip]:after {
|
||||||
|
display: none !important;
|
||||||
|
content: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-tools-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
gap: 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container.single-row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show full text on larger screens, hide short text */
|
||||||
|
.full-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset text always visible by default */
|
||||||
|
.reset-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.play-buttons-group {
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 0 0 auto; /* Don't expand to fill space */
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto; /* Push to right edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #333;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: auto;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 1.25rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style for play buttons with highlight effect */
|
||||||
|
.play-button,
|
||||||
|
.preview-button {
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Greyed out play button when segments are playing */
|
||||||
|
.play-button.greyed-out {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlighted stop button with blue pulse on small screens */
|
||||||
|
.segments-button.highlighted-stop {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
animation: bluePulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bluePulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completely disable ALL hover effects for play buttons */
|
||||||
|
.play-button:hover:not(:disabled),
|
||||||
|
.preview-button:hover:not(:disabled) {
|
||||||
|
/* Reset everything to prevent any changes */
|
||||||
|
color: inherit !important;
|
||||||
|
transform: none !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
width: auto !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button svg,
|
||||||
|
.preview-button svg {
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
/* Make sure SVG scales with the button but doesn't change layout */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add responsive button text class */
|
||||||
|
.button-text {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media queries for the editing tools */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
/* Hide text for undo/redo buttons on medium screens */
|
||||||
|
.button-group.secondary .button-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* Keep all buttons in a single row, make them more compact */
|
||||||
|
.flex-container.single-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep font size consistent regardless of screen size */
|
||||||
|
.preview-button,
|
||||||
|
.play-button {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* Prevent container overflow on mobile */
|
||||||
|
.editing-tools-container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* At this breakpoint, make preview button text shorter */
|
||||||
|
.preview-button {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch to short text versions */
|
||||||
|
.full-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-text {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide reset text */
|
||||||
|
.reset-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure buttons stay in correct position */
|
||||||
|
.button-group.play-buttons-group {
|
||||||
|
flex: initial;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group.secondary {
|
||||||
|
flex: initial;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce button sizes on mobile */
|
||||||
|
.button-group button {
|
||||||
|
padding: 0.375rem;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button svg {
|
||||||
|
height: 1.125rem;
|
||||||
|
width: 1.125rem;
|
||||||
|
margin-right: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
/* Keep single row, left-align play buttons, right-align controls */
|
||||||
|
.flex-container.single-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix left-align for play buttons */
|
||||||
|
.button-group.play-buttons-group {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix right-align for editing controls */
|
||||||
|
.button-group.secondary {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce button padding to fit more easily */
|
||||||
|
.button-group button {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Very small screens - maintain layout but reduce further */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.editing-tools-container {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container.single-row {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group.play-buttons-group,
|
||||||
|
.button-group.secondary {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: none; /* Hide divider on very small screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Even smaller buttons on very small screens */
|
||||||
|
.button-group button {
|
||||||
|
padding: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button svg {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide all button text on very small screens */
|
||||||
|
.button-text,
|
||||||
|
.reset-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portrait orientation specific fixes */
|
||||||
|
@media (max-width: 640px) and (orientation: portrait) {
|
||||||
|
.editing-tools-container {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container.single-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure button groups don't overflow */
|
||||||
|
.button-group {
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group.play-buttons-group {
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group.secondary {
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
.ios-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background-color: #fffdeb;
|
||||||
|
border-bottom: 1px solid #e2e2e2;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 10px;
|
||||||
|
animation: slide-down 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #0066cc;
|
||||||
|
margin-right: 15px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message h3 {
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message li {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-close:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop mode button styling */
|
||||||
|
.ios-mode-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-desktop-mode-btn {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-desktop-mode-btn:hover {
|
||||||
|
background-color: #0055aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-desktop-mode-btn:active {
|
||||||
|
background-color: #004499;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-or {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS-specific styles */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.ios-notification {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-close {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure this notification has better visibility on smaller screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ios-notification-content {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-notification-message p,
|
||||||
|
.ios-notification-message ol {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add iOS-specific styles when in desktop mode */
|
||||||
|
html.ios-device {
|
||||||
|
/* Force the content to be rendered at desktop width */
|
||||||
|
min-width: 1024px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.ios-device .ios-control-btn {
|
||||||
|
/* Make buttons easier to tap in desktop mode */
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
.mobile-play-prompt-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-prompt {
|
||||||
|
background-color: white;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-prompt h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-prompt p {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-prompt-instructions {
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: left;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-prompt-instructions p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-prompt-instructions ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-prompt-instructions li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-button {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-top: 5px;
|
||||||
|
/* Make button easier to tap on mobile */
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-button:hover {
|
||||||
|
background-color: #0069d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-play-button:active {
|
||||||
|
background-color: #0062cc;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special styles for mobile devices */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.mobile-play-button {
|
||||||
|
/* Extra spacing for mobile */
|
||||||
|
padding: 14px 25px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
.ios-video-player-container {
|
||||||
|
position: relative;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-video-player-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-time-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-note {
|
||||||
|
text-align: center;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS-specific styling tweaks */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.ios-video-player-container video {
|
||||||
|
max-height: 50vh; /* Use viewport height on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve controls visibility on iOS */
|
||||||
|
video::-webkit-media-controls {
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure controls don't disappear too quickly */
|
||||||
|
video::-webkit-media-controls-panel {
|
||||||
|
transition-duration: 3s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* External controls styling */
|
||||||
|
.ios-external-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-control-btn {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 100px;
|
||||||
|
height: 44px; /* Minimum touch target size for iOS */
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-control-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on buttons */
|
||||||
|
.no-select {
|
||||||
|
-webkit-touch-callout: none; /* iOS Safari */
|
||||||
|
-webkit-user-select: none; /* Safari */
|
||||||
|
-khtml-user-select: none; /* Konqueror HTML */
|
||||||
|
-moz-user-select: none; /* Firefox */
|
||||||
|
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||||
|
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specifically prevent default behavior on fine controls */
|
||||||
|
.ios-fine-controls button,
|
||||||
|
.ios-external-controls .no-select {
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
306
frontend-tools/chapters-editor/client/src/styles/Modal.css
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
#chapters-editor-root {
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: modal-fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-primary {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-primary:hover {
|
||||||
|
background-color: #0055aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-secondary {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-secondary:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button-danger:hover {
|
||||||
|
background-color: #bd2130;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal content styles */
|
||||||
|
.modal-message {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 4px solid #0066cc;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-success-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-success-icon svg {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
color: #4caf50;
|
||||||
|
animation: success-pop 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes success-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-icon svg {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
color: #f44336;
|
||||||
|
animation: error-pop 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-choice-button {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #0066cc;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-choice-button:hover {
|
||||||
|
background-color: #0055aa;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-choice-button svg {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-link {
|
||||||
|
background-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-link:hover {
|
||||||
|
background-color: #3d8b40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-choice {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 220px;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-choice:hover {
|
||||||
|
background-color: #0055aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-container {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #f44336;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: rgba(244, 67, 54, 0.1);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid #f44336;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect-message {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0066cc;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
342
frontend-tools/chapters-editor/client/src/styles/VideoPlayer.css
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
#chapters-editor-root {
|
||||||
|
/* Tooltip styles - only on desktop where hover is available */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:before {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
visibility 0.2s;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-width: 5px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s,
|
||||||
|
visibility 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover:before,
|
||||||
|
[data-tooltip]:hover:after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide button tooltips on touch devices */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
[data-tooltip]:before,
|
||||||
|
[data-tooltip]:after {
|
||||||
|
display: none !important;
|
||||||
|
content: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.video-player-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
/* Prevent iOS Safari from showing default video controls */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
/* Force hardware acceleration */
|
||||||
|
transform: translateZ(0);
|
||||||
|
-webkit-transform: translateZ(0);
|
||||||
|
/* Prevent iOS Safari from showing default video controls */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS-specific styles */
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.video-player-container video {
|
||||||
|
/* Additional iOS optimizations */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container:hover .play-pause-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-indicator::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-indicator.play-icon::before {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 15px solid transparent;
|
||||||
|
border-bottom: 15px solid transparent;
|
||||||
|
border-left: 25px solid white;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-indicator.pause-icon::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 25px;
|
||||||
|
border-left: 6px solid white;
|
||||||
|
border-right: 6px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS First-play indicator */
|
||||||
|
.ios-first-play-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-play-message {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-container:hover .video-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-current-time {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-duration {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-time-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress {
|
||||||
|
position: relative;
|
||||||
|
height: 6px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 10px;
|
||||||
|
touch-action: none; /* Prevent browser handling of drag gestures */
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress.dragging {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border-radius: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-scrubber {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
transition:
|
||||||
|
transform 0.1s ease,
|
||||||
|
width 0.1s ease,
|
||||||
|
height 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the scrubber larger when dragging for better control */
|
||||||
|
.video-progress.dragging .video-scrubber {
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: grabbing;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhance for touch devices */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.video-scrubber {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress.dragging .video-scrubber {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create a larger invisible touch target */
|
||||||
|
.video-scrubber:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: -10px;
|
||||||
|
right: -10px;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-button,
|
||||||
|
.fullscreen-button {
|
||||||
|
min-width: auto;
|
||||||
|
color: white;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time tooltip that appears when dragging */
|
||||||
|
.video-time-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a small arrow to the tooltip */
|
||||||
|
.video-time-tooltip:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 4px solid rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend-tools/chapters-editor/client/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
20
frontend-tools/chapters-editor/components.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "client/src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend-tools/chapters-editor/generated-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
47
frontend-tools/chapters-editor/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "video-trim-js",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
|
"check": "tsc",
|
||||||
|
"build:django": "vite build --config vite.chapters-editor.config.ts --outDir ../../../static/chapters_editor",
|
||||||
|
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.74.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"zod": "^3.24.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/express": "4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/node": "^20.17.30",
|
||||||
|
"@types/passport": "^1.0.16",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/react": "^18.3.20",
|
||||||
|
"@types/react-dom": "^18.3.6",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"prettier": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^5.4.18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"bufferutil": "^4.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend-tools/chapters-editor/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
9
frontend-tools/chapters-editor/shared/schema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const insertUserSchema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
|
export type User = InsertUser & { id: number };
|
||||||
90
frontend-tools/chapters-editor/tailwind.config.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
chart: {
|
||||||
|
"1": "hsl(var(--chart-1))",
|
||||||
|
"2": "hsl(var(--chart-2))",
|
||||||
|
"3": "hsl(var(--chart-3))",
|
||||||
|
"4": "hsl(var(--chart-4))",
|
||||||
|
"5": "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
primary: "hsl(var(--sidebar-primary))",
|
||||||
|
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||||
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: {
|
||||||
|
height: "0",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: {
|
||||||
|
height: "var(--radix-accordion-content-height)",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
|
} satisfies Config;
|
||||||
22
frontend-tools/chapters-editor/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"include": ["client/src/**/*"],
|
||||||
|
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["esnext", "dom", "dom.iterable"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["node", "vite/client"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./client/src/*"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,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,
|
||||||
|
},
|
||||||
|
});
|
||||||
22
frontend-tools/chapters-editor/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Get current directory
|
||||||
|
const __dirname = path.resolve();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "client", "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
root: path.resolve(__dirname, "client"),
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, "dist/public"),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
2330
frontend-tools/chapters-editor/yarn.lock
Normal 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -16,9 +16,6 @@ A modern browser-based video editing tool built with React and TypeScript that i
|
|||||||
- React 18
|
- React 18
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- Vite
|
- Vite
|
||||||
- Tailwind CSS
|
|
||||||
- Express (for development server)
|
|
||||||
- Drizzle ORM
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
BIN
frontend-tools/video-editor/client/public/audio-poster.jpg
Normal file
|
After Width: | Height: | Size: 695 KiB |
@ -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 (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -1,85 +1,83 @@
|
|||||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
import { formatTime, formatLongTime } from '@/lib/timeUtils';
|
||||||
import "../styles/ClipSegments.css";
|
import '../styles/ClipSegments.css';
|
||||||
|
|
||||||
export interface Segment {
|
export interface Segment {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipSegmentsProps {
|
interface ClipSegmentsProps {
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||||
// Sort segments by startTime
|
// Sort segments by startTime
|
||||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Handle delete segment click
|
// Handle delete segment click
|
||||||
const handleDeleteSegment = (segmentId: number) => {
|
const handleDeleteSegment = (segmentId: number) => {
|
||||||
// Create and dispatch the delete event
|
// Create and dispatch the delete event
|
||||||
const deleteEvent = new CustomEvent("delete-segment", {
|
const deleteEvent = new CustomEvent('delete-segment', {
|
||||||
detail: { segmentId }
|
detail: { segmentId },
|
||||||
});
|
});
|
||||||
document.dispatchEvent(deleteEvent);
|
document.dispatchEvent(deleteEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate the same color background for a segment as shown in the timeline
|
// Generate the same color background for a segment as shown in the timeline
|
||||||
const getSegmentColorClass = (index: number) => {
|
const getSegmentColorClass = (index: number) => {
|
||||||
// Return CSS class based on index modulo 8
|
// Return CSS class based on index modulo 8
|
||||||
// This matches the CSS nth-child selectors in the timeline
|
// This matches the CSS nth-child selectors in the timeline
|
||||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="clip-segments-container">
|
<div className="clip-segments-container">
|
||||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||||
|
|
||||||
{sortedSegments.map((segment, index) => (
|
{sortedSegments.map((segment, index) => (
|
||||||
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
||||||
<div className="segment-content">
|
<div className="segment-content">
|
||||||
<div
|
<div
|
||||||
className="segment-thumbnail"
|
className="segment-thumbnail"
|
||||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||||
></div>
|
></div>
|
||||||
<div className="segment-info">
|
<div className="segment-info">
|
||||||
<div className="segment-title">Segment {index + 1}</div>
|
<div className="segment-title">Segment {index + 1}</div>
|
||||||
<div className="segment-time">
|
<div className="segment-time">
|
||||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="segment-duration">
|
<div className="segment-duration">
|
||||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="segment-actions">
|
<div className="segment-actions">
|
||||||
<button
|
<button
|
||||||
className="delete-button"
|
className="delete-button"
|
||||||
aria-label="Delete Segment"
|
aria-label="Delete Segment"
|
||||||
data-tooltip="Delete this segment"
|
data-tooltip="Delete this segment"
|
||||||
onClick={() => handleDeleteSegment(segment.id)}
|
onClick={() => handleDeleteSegment(segment.id)}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sortedSegments.length === 0 && (
|
||||||
|
<div className="empty-message">No segments created yet. Use the split button to create segments.</div>
|
||||||
|
)}
|
||||||
</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;
|
export default ClipSegments;
|
||||||
|
|||||||
@ -1,108 +1,109 @@
|
|||||||
import "../styles/EditingTools.css";
|
import '../styles/EditingTools.css';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
|
import logger from '@/lib/logger';
|
||||||
|
|
||||||
interface EditingToolsProps {
|
interface EditingToolsProps {
|
||||||
onSplit: () => void;
|
onSplit: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onPlaySegments: () => void;
|
onPlaySegments: () => void;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
isPlaying?: boolean;
|
isPlaying?: boolean;
|
||||||
isPlayingSegments?: boolean;
|
isPlayingSegments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditingTools = ({
|
const EditingTools = ({
|
||||||
onSplit,
|
onSplit,
|
||||||
onReset,
|
onReset,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onPlaySegments,
|
onPlaySegments,
|
||||||
onPlay,
|
onPlay,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
isPlayingSegments = false
|
isPlayingSegments = false,
|
||||||
}: EditingToolsProps) => {
|
}: EditingToolsProps) => {
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkScreenSize = () => {
|
const checkScreenSize = () => {
|
||||||
setIsSmallScreen(window.innerWidth <= 640);
|
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();
|
return (
|
||||||
window.addEventListener("resize", checkScreenSize);
|
<div className="editing-tools-container">
|
||||||
return () => window.removeEventListener("resize", checkScreenSize);
|
<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
|
{/* Play Preview button */}
|
||||||
const handlePlay = () => {
|
{/* <button
|
||||||
// Ensure lastSeekedPosition is used when play is clicked
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the original handler
|
|
||||||
onPlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="editing-tools-container">
|
|
||||||
<div className="flex-container single-row">
|
|
||||||
{/* Left side - Play buttons group */}
|
|
||||||
<div className="button-group play-buttons-group">
|
|
||||||
{/* Play Segments button */}
|
|
||||||
<button
|
|
||||||
className={`button segments-button`}
|
|
||||||
onClick={onPlaySegments}
|
|
||||||
data-tooltip={
|
|
||||||
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
|
|
||||||
}
|
|
||||||
style={{ fontSize: "0.875rem" }}
|
|
||||||
>
|
|
||||||
{isPlayingSegments ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="10" y1="15" x2="10" y2="9" />
|
|
||||||
<line x1="14" y1="15" x2="14" y2="9" />
|
|
||||||
</svg>
|
|
||||||
<span className="full-text">Stop Preview</span>
|
|
||||||
<span className="short-text">Stop Preview</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polygon points="10 8 16 12 10 16 10 8" />
|
|
||||||
</svg>
|
|
||||||
<span className="full-text">Play Preview</span>
|
|
||||||
<span className="short-text">Play Preview</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Play Preview button */}
|
|
||||||
{/* <button
|
|
||||||
className="button preview-button"
|
className="button preview-button"
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||||
@ -130,56 +131,56 @@ const EditingTools = ({
|
|||||||
)}
|
)}
|
||||||
</button> */}
|
</button> */}
|
||||||
|
|
||||||
{/* Standard Play button (only shown when not in segments playback on small screens) */}
|
{/* Standard Play button (only shown when not in segments playback on small screens) */}
|
||||||
{(!isPlayingSegments || !isSmallScreen) && (
|
{(!isPlayingSegments || !isSmallScreen) && (
|
||||||
<button
|
<button
|
||||||
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
|
className={`button play-button ${isPlayingSegments ? 'greyed-out' : ''}`}
|
||||||
onClick={handlePlay}
|
onClick={handlePlay}
|
||||||
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
|
data-tooltip={isPlaying ? 'Pause video' : 'Play full video'}
|
||||||
style={{ fontSize: "0.875rem" }}
|
style={{ fontSize: '0.875rem' }}
|
||||||
disabled={isPlayingSegments}
|
disabled={isPlayingSegments}
|
||||||
>
|
>
|
||||||
{isPlaying && !isPlayingSegments ? (
|
{isPlaying && !isPlayingSegments ? (
|
||||||
<>
|
<>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="10" y1="15" x2="10" y2="9" />
|
<line x1="10" y1="15" x2="10" y2="9" />
|
||||||
<line x1="14" y1="15" x2="14" y2="9" />
|
<line x1="14" y1="15" x2="14" y2="9" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="full-text">Pause</span>
|
<span className="full-text">Pause</span>
|
||||||
<span className="short-text">Pause</span>
|
<span className="short-text">Pause</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<polygon points="10 8 16 12 10 16 10 8" />
|
<polygon points="10 8 16 12 10 16 10 8" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="full-text">Play</span>
|
<span className="full-text">Play</span>
|
||||||
<span className="short-text">Play</span>
|
<span className="short-text">Play</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||||
{/* {isPlayingSegments && !isSmallScreen && (
|
{/* {isPlayingSegments && !isSmallScreen && (
|
||||||
<div className="segments-playback-message">
|
<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">
|
<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" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
@ -190,8 +191,8 @@ const EditingTools = ({
|
|||||||
</div>
|
</div>
|
||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
{/* Preview mode message (replaces play button) */}
|
{/* Preview mode message (replaces play button) */}
|
||||||
{/* {isPreviewMode && (
|
{/* {isPreviewMode && (
|
||||||
<div className="preview-mode-message">
|
<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">
|
<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" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
@ -201,72 +202,72 @@ const EditingTools = ({
|
|||||||
Preview Mode
|
Preview Mode
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Editing tools */}
|
{/* Right side - Editing tools */}
|
||||||
<div className="button-group secondary">
|
<div className="button-group secondary">
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
aria-label="Undo"
|
aria-label="Undo"
|
||||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
|
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Undo last action'}
|
||||||
disabled={!canUndo || isPlayingSegments}
|
disabled={!canUndo || isPlayingSegments}
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<path d="M9 14 4 9l5-5" />
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="button-text">Undo</span>
|
<span className="button-text">Undo</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
aria-label="Redo"
|
aria-label="Redo"
|
||||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
|
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Redo last undone action'}
|
||||||
disabled={!canRedo || isPlayingSegments}
|
disabled={!canRedo || isPlayingSegments}
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<path d="m15 14 5-5-5-5" />
|
<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" />
|
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="button-text">Redo</span>
|
<span className="button-text">Redo</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="divider"></div>
|
<div className="divider"></div>
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
|
data-tooltip={isPlayingSegments ? 'Disabled during preview' : 'Reset to full video'}
|
||||||
disabled={isPlayingSegments}
|
disabled={isPlayingSegments}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="reset-text">Reset</span>
|
<span className="reset-text">Reset</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditingTools;
|
export default EditingTools;
|
||||||
|
|||||||
@ -1,55 +1,55 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from 'react';
|
||||||
import "../styles/IOSPlayPrompt.css";
|
import '../styles/IOSPlayPrompt.css';
|
||||||
|
|
||||||
interface MobilePlayPromptProps {
|
interface MobilePlayPromptProps {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>;
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
// Check if the device is mobile
|
// Check if the device is mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkIsMobile = () => {
|
const checkIsMobile = () => {
|
||||||
// More comprehensive check for mobile/tablet devices
|
// More comprehensive check for mobile/tablet devices
|
||||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||||
navigator.userAgent
|
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
|
if (!isVisible) return null;
|
||||||
const isMobile = checkIsMobile();
|
|
||||||
setIsVisible(isMobile);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close the prompt when video plays
|
return (
|
||||||
useEffect(() => {
|
<div className="mobile-play-prompt-overlay">
|
||||||
const video = videoRef.current;
|
<div className="mobile-play-prompt">
|
||||||
if (!video) return;
|
{/* <h3>Mobile Device Notice</h3>
|
||||||
|
|
||||||
const handlePlay = () => {
|
|
||||||
// Just close the prompt when video plays
|
|
||||||
setIsVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener("play", handlePlay);
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener("play", handlePlay);
|
|
||||||
};
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const handlePlayClick = () => {
|
|
||||||
onPlay();
|
|
||||||
// Prompt will be closed by the play event handler
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVisible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-play-prompt-overlay">
|
|
||||||
<div className="mobile-play-prompt">
|
|
||||||
{/* <h3>Mobile Device Notice</h3>
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
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>
|
</ol>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||||
Click to start editing...
|
Click to start editing...
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MobilePlayPrompt;
|
export default MobilePlayPrompt;
|
||||||
|
|||||||
@ -1,184 +1,197 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { formatTime } from "@/lib/timeUtils";
|
import { formatTime } from '@/lib/timeUtils';
|
||||||
import "../styles/IOSVideoPlayer.css";
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
|
import '../styles/IOSVideoPlayer.css';
|
||||||
|
|
||||||
interface IOSVideoPlayerProps {
|
interface IOSVideoPlayerProps {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>;
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||||
|
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Refs for hold-to-continue functionality
|
// Refs for hold-to-continue functionality
|
||||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Clean up intervals on unmount
|
// Clean up intervals on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.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
|
// Function to jump 15 seconds forward
|
||||||
useEffect(() => {
|
const jumpForward15 = () => {
|
||||||
if (videoRef.current && videoRef.current.querySelector("source")) {
|
if (iosVideoRef) {
|
||||||
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
|
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
||||||
if (source && source.src) {
|
iosVideoRef.currentTime = newTime;
|
||||||
setVideoUrl(source.src);
|
}
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
// Fallback to sample video if needed
|
|
||||||
setVideoUrl("/videos/sample-video-10m.mp4");
|
|
||||||
}
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
// Start continuous 50ms increment when button is held
|
||||||
const jumpBackward15 = () => {
|
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
if (iosVideoRef) {
|
// Prevent default to avoid text selection
|
||||||
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
|
e.preventDefault();
|
||||||
iosVideoRef.currentTime = newTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to jump 15 seconds forward
|
if (!iosVideoRef) return;
|
||||||
const jumpForward15 = () => {
|
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||||
if (iosVideoRef) {
|
|
||||||
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
|
||||||
iosVideoRef.currentTime = newTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start continuous 50ms increment when button is held
|
// First immediate adjustment
|
||||||
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);
|
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stop continuous increment
|
// Setup continuous adjustment
|
||||||
const stopIncrement = () => {
|
incrementIntervalRef.current = setInterval(() => {
|
||||||
if (incrementIntervalRef.current) {
|
if (iosVideoRef) {
|
||||||
clearInterval(incrementIntervalRef.current);
|
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||||
incrementIntervalRef.current = null;
|
}
|
||||||
}
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start continuous 50ms decrement when button is held
|
// Stop continuous increment
|
||||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
const stopIncrement = () => {
|
||||||
// Prevent default to avoid text selection
|
if (incrementIntervalRef.current) {
|
||||||
e.preventDefault();
|
clearInterval(incrementIntervalRef.current);
|
||||||
|
incrementIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!iosVideoRef) return;
|
// Start continuous 50ms decrement when button is held
|
||||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
// Prevent default to avoid text selection
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
// First immediate adjustment
|
if (!iosVideoRef) return;
|
||||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||||
|
|
||||||
// Setup continuous adjustment
|
// First immediate adjustment
|
||||||
decrementIntervalRef.current = setInterval(() => {
|
|
||||||
if (iosVideoRef) {
|
|
||||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stop continuous decrement
|
// Setup continuous adjustment
|
||||||
const stopDecrement = () => {
|
decrementIntervalRef.current = setInterval(() => {
|
||||||
if (decrementIntervalRef.current) {
|
if (iosVideoRef) {
|
||||||
clearInterval(decrementIntervalRef.current);
|
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||||
decrementIntervalRef.current = null;
|
}
|
||||||
}
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Stop continuous decrement
|
||||||
<div className="ios-video-player-container">
|
const stopDecrement = () => {
|
||||||
{/* Current Time / Duration Display */}
|
if (decrementIntervalRef.current) {
|
||||||
<div className="ios-time-display mb-2">
|
clearInterval(decrementIntervalRef.current);
|
||||||
<span className="text-sm">
|
decrementIntervalRef.current = null;
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
}
|
||||||
</span>
|
};
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* iOS-optimized Video Element with Native Controls */}
|
return (
|
||||||
<video
|
<div className="ios-video-player-container">
|
||||||
ref={(ref) => setIosVideoRef(ref)}
|
{/* Current Time / Duration Display */}
|
||||||
className="w-full rounded-md"
|
<div className="ios-time-display mb-2">
|
||||||
src={videoUrl}
|
<span className="text-sm">
|
||||||
controls
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
playsInline
|
</span>
|
||||||
webkit-playsinline="true"
|
</div>
|
||||||
x-webkit-airplay="allow"
|
|
||||||
preload="auto"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
>
|
|
||||||
<source src={videoUrl} type="video/mp4" />
|
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{/* iOS Video Skip Controls */}
|
{/* iOS-optimized Video Element with Native Controls */}
|
||||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
<video
|
||||||
<button
|
ref={(ref) => setIosVideoRef(ref)}
|
||||||
onClick={jumpBackward15}
|
className="w-full rounded-md"
|
||||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
src={videoUrl}
|
||||||
>
|
controls
|
||||||
-15s
|
playsInline
|
||||||
</button>
|
webkit-playsinline="true"
|
||||||
<button
|
x-webkit-airplay="allow"
|
||||||
onClick={jumpForward15}
|
preload="auto"
|
||||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
crossOrigin="anonymous"
|
||||||
>
|
poster={posterImage}
|
||||||
+15s
|
>
|
||||||
</button>
|
<source src={videoUrl} type="video/mp4" />
|
||||||
</div>
|
<p>Your browser doesn't support HTML5 video.</p>
|
||||||
|
</video>
|
||||||
|
|
||||||
{/* iOS Fine Control Buttons */}
|
{/* iOS Video Skip Controls */}
|
||||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||||
<button
|
<button
|
||||||
onMouseDown={startDecrement}
|
onClick={jumpBackward15}
|
||||||
onTouchStart={startDecrement}
|
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||||
onMouseUp={stopDecrement}
|
>
|
||||||
onMouseLeave={stopDecrement}
|
-15s
|
||||||
onTouchEnd={stopDecrement}
|
</button>
|
||||||
onTouchCancel={stopDecrement}
|
<button
|
||||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
onClick={jumpForward15}
|
||||||
>
|
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||||
-50ms
|
>
|
||||||
</button>
|
+15s
|
||||||
<button
|
</button>
|
||||||
onMouseDown={startIncrement}
|
</div>
|
||||||
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">
|
{/* iOS Fine Control Buttons */}
|
||||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||||
</div>
|
<button
|
||||||
</div>
|
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;
|
export default IOSVideoPlayer;
|
||||||
|
|||||||
@ -1,74 +1,74 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from 'react';
|
||||||
import "../styles/Modal.css";
|
import '../styles/Modal.css';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||||
// Close modal when Escape key is pressed
|
// Close modal when Escape key is pressed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape" && isOpen) {
|
if (event.key === 'Escape' && isOpen) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
// 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
|
<div className="modal-content">{children}</div>
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
{actions && <div className="modal-actions">{actions}</div>}
|
||||||
document.removeEventListener("keydown", handleEscapeKey);
|
</div>
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
// Handle click outside the modal content to close it
|
|
||||||
const handleClickOutside = (event: React.MouseEvent) => {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
|
||||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2 className="modal-title">{title}</h2>
|
|
||||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="modal-content">{children}</div>
|
|
||||||
|
|
||||||
{actions && <div className="modal-actions">{actions}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|||||||
@ -1,452 +1,479 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
||||||
import logger from "../lib/logger";
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
import "../styles/VideoPlayer.css";
|
import logger from '../lib/logger';
|
||||||
|
import '../styles/VideoPlayer.css';
|
||||||
|
|
||||||
interface VideoPlayerProps {
|
interface VideoPlayerProps {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>;
|
videoRef: React.RefObject<HTMLVideoElement>;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
onPlayPause: () => void;
|
onPlayPause: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
onToggleMute?: () => void;
|
onToggleMute?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
videoRef,
|
videoRef,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
onPlayPause,
|
onPlayPause,
|
||||||
onSeek,
|
onSeek,
|
||||||
onToggleMute
|
onToggleMute,
|
||||||
}) => {
|
}) => {
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
||||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||||
const isDraggingProgressRef = useRef(false);
|
const isDraggingProgressRef = useRef(false);
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
|
const [tooltipPosition, setTooltipPosition] = useState({
|
||||||
const [tooltipTime, setTooltipTime] = useState(0);
|
x: 0,
|
||||||
|
});
|
||||||
|
const [tooltipTime, setTooltipTime] = useState(0);
|
||||||
|
|
||||||
const sampleVideoUrl =
|
const sampleVideoUrl =
|
||||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
|
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp3';
|
||||||
"/videos/sample-video-10m.mp4";
|
|
||||||
|
|
||||||
// Detect iOS device
|
// Check if the media is an audio file
|
||||||
useEffect(() => {
|
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
const checkIOS = () => {
|
|
||||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
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
|
setIsIOS(checkIOS());
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const wasInitialized = localStorage.getItem("video_initialized") === "true";
|
|
||||||
setHasInitialized(wasInitialized);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update initialized state when video plays
|
// Check if video was previously initialized
|
||||||
useEffect(() => {
|
if (typeof window !== 'undefined') {
|
||||||
if (isPlaying && !hasInitialized) {
|
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||||
setHasInitialized(true);
|
setHasInitialized(wasInitialized);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
// 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
|
// Jump 10 seconds backward
|
||||||
const handlePlay = () => {
|
const handleBackward = () => {
|
||||||
logger.debug("Video play event fired");
|
const newTime = Math.max(currentTime - 10, 0);
|
||||||
if (isIOS) {
|
onSeek(newTime);
|
||||||
setHasInitialized(true);
|
setLastPosition(newTime);
|
||||||
localStorage.setItem("video_initialized", "true");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePause = () => {
|
// Calculate progress percentage
|
||||||
logger.debug("Video pause event fired");
|
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);
|
// Handle progress dragging for both mouse and touch events
|
||||||
video.addEventListener("play", handlePlay);
|
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||||
video.addEventListener("pause", handlePause);
|
if (!progressRef.current) return;
|
||||||
|
|
||||||
return () => {
|
const rect = progressRef.current.getBoundingClientRect();
|
||||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
video.removeEventListener("play", handlePlay);
|
const seekTime = duration * clickPosition;
|
||||||
video.removeEventListener("pause", handlePause);
|
|
||||||
};
|
|
||||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
|
||||||
|
|
||||||
// Save current time to lastPosition when it changes (from external seeking)
|
// Update tooltip position and time
|
||||||
useEffect(() => {
|
setTooltipPosition({
|
||||||
setLastPosition(currentTime);
|
x: e.clientX,
|
||||||
}, [currentTime]);
|
});
|
||||||
|
setTooltipTime(seekTime);
|
||||||
|
|
||||||
// Jump 10 seconds forward
|
// Store position locally for iOS Safari - critical for timeline seeking
|
||||||
const handleForward = () => {
|
setLastPosition(seekTime);
|
||||||
const newTime = Math.min(currentTime + 10, duration);
|
|
||||||
onSeek(newTime);
|
|
||||||
setLastPosition(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Jump 10 seconds backward
|
// Also store globally for integration with other components
|
||||||
const handleBackward = () => {
|
if (typeof window !== 'undefined') {
|
||||||
const newTime = Math.max(currentTime - 10, 0);
|
(window as any).lastSeekedPosition = seekTime;
|
||||||
onSeek(newTime);
|
}
|
||||||
setLastPosition(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate progress percentage
|
onSeek(seekTime);
|
||||||
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 = () => {
|
// Handle touch events for progress bar
|
||||||
setIsDraggingProgress(false);
|
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||||
isDraggingProgressRef.current = false;
|
if (!progressRef.current || !e.touches[0]) return;
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
e.preventDefault();
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
|
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);
|
// Handle touch dragging on progress bar
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||||
};
|
if (!progressRef.current) return;
|
||||||
|
|
||||||
// Handle progress dragging for both mouse and touch events
|
// Get the touch coordinates
|
||||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
const touch = 'touches' in e ? e.touches[0] : null;
|
||||||
if (!progressRef.current) return;
|
if (!touch) return;
|
||||||
|
|
||||||
const rect = progressRef.current.getBoundingClientRect();
|
e.preventDefault(); // Prevent scrolling while dragging
|
||||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
||||||
const seekTime = duration * clickPosition;
|
|
||||||
|
|
||||||
// Update tooltip position and time
|
const rect = progressRef.current.getBoundingClientRect();
|
||||||
setTooltipPosition({ x: e.clientX });
|
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||||
setTooltipTime(seekTime);
|
const seekTime = duration * touchPosition;
|
||||||
|
|
||||||
// Store position locally for iOS Safari - critical for timeline seeking
|
// Update tooltip position and time
|
||||||
setLastPosition(seekTime);
|
setTooltipPosition({
|
||||||
|
x: touch.clientX,
|
||||||
|
});
|
||||||
|
setTooltipTime(seekTime);
|
||||||
|
|
||||||
// Also store globally for integration with other components
|
// Store position for iOS Safari
|
||||||
if (typeof window !== "undefined") {
|
setLastPosition(seekTime);
|
||||||
(window as any).lastSeekedPosition = 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
|
onSeek(seekTime);
|
||||||
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 = () => {
|
// Handle click on progress bar (for non-drag interactions)
|
||||||
setIsDraggingProgress(false);
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
isDraggingProgressRef.current = false;
|
// If we're already dragging, don't handle the click
|
||||||
document.removeEventListener("touchmove", handleTouchMove);
|
if (isDraggingProgress) return;
|
||||||
document.removeEventListener("touchend", handleTouchEnd);
|
|
||||||
document.removeEventListener("touchcancel", handleTouchEnd);
|
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 });
|
// Handle toggling fullscreen
|
||||||
document.addEventListener("touchend", handleTouchEnd);
|
const handleFullscreen = () => {
|
||||||
document.addEventListener("touchcancel", handleTouchEnd);
|
if (videoRef.current) {
|
||||||
};
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
videoRef.current.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle touch dragging on progress bar
|
// Handle click on video to play/pause
|
||||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
const handleVideoClick = () => {
|
||||||
if (!progressRef.current) return;
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
// Get the touch coordinates
|
// If the video is paused, we want to play it
|
||||||
const touch = "touches" in e ? e.touches[0] : null;
|
if (video.paused) {
|
||||||
if (!touch) return;
|
// 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();
|
// Use a small timeout to ensure seeking is complete before play
|
||||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
setTimeout(() => {
|
||||||
const seekTime = duration * touchPosition;
|
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
|
return (
|
||||||
setTooltipPosition({ x: touch.clientX });
|
<div className="video-player-container">
|
||||||
setTooltipTime(seekTime);
|
<video
|
||||||
|
ref={videoRef}
|
||||||
// Store position for iOS Safari
|
preload="auto"
|
||||||
setLastPosition(seekTime);
|
crossOrigin="anonymous"
|
||||||
|
onClick={handleVideoClick}
|
||||||
// Also store globally for integration with other components
|
playsInline
|
||||||
if (typeof window !== "undefined") {
|
webkit-playsinline="true"
|
||||||
(window as any).lastSeekedPosition = seekTime;
|
x-webkit-airplay="allow"
|
||||||
}
|
controls={false}
|
||||||
|
muted={isMuted}
|
||||||
onSeek(seekTime);
|
poster={posterImage}
|
||||||
};
|
|
||||||
|
|
||||||
// Handle click on progress bar (for non-drag interactions)
|
|
||||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
// If we're already dragging, don't handle the click
|
|
||||||
if (isDraggingProgress) return;
|
|
||||||
|
|
||||||
if (progressRef.current) {
|
|
||||||
const rect = progressRef.current.getBoundingClientRect();
|
|
||||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
|
||||||
const seekTime = duration * clickPosition;
|
|
||||||
|
|
||||||
// Store position locally for iOS Safari - critical for timeline seeking
|
|
||||||
setLastPosition(seekTime);
|
|
||||||
|
|
||||||
// Also store globally for integration with other components
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
(window as any).lastSeekedPosition = seekTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSeek(seekTime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle toggling fullscreen
|
|
||||||
const handleFullscreen = () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
videoRef.current.requestFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle click on video to play/pause
|
|
||||||
const handleVideoClick = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
// If the video is paused, we want to play it
|
|
||||||
if (video.paused) {
|
|
||||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
|
||||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
|
||||||
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
|
|
||||||
|
|
||||||
// First, seek to the position
|
|
||||||
video.currentTime = lastPosition;
|
|
||||||
|
|
||||||
// Use a small timeout to ensure seeking is complete before play
|
|
||||||
setTimeout(() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
// Try to play with proper promise handling
|
|
||||||
videoRef.current
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
logger.debug(
|
|
||||||
"iOS: Play started successfully at position:",
|
|
||||||
videoRef.current?.currentTime
|
|
||||||
);
|
|
||||||
onPlayPause(); // Update parent state after successful play
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("iOS: Error playing video:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
} else {
|
|
||||||
// Normal play (non-iOS or no remembered position)
|
|
||||||
video
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
logger.debug("Normal: Play started successfully");
|
|
||||||
onPlayPause(); // Update parent state after successful play
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Error playing video:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If playing, pause and update state
|
|
||||||
video.pause();
|
|
||||||
onPlayPause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="video-player-container">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
preload="auto"
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
onClick={handleVideoClick}
|
|
||||||
playsInline
|
|
||||||
webkit-playsinline="true"
|
|
||||||
x-webkit-airplay="allow"
|
|
||||||
controls={false}
|
|
||||||
muted={isMuted}
|
|
||||||
>
|
|
||||||
<source src={sampleVideoUrl} type="video/mp4" />
|
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
|
||||||
{isIOS && !hasInitialized && !isPlaying && (
|
|
||||||
<div className="ios-first-play-indicator">
|
|
||||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Play/Pause Indicator (shows based on current state) */}
|
|
||||||
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
|
|
||||||
|
|
||||||
{/* Video Controls Overlay */}
|
|
||||||
<div className="video-controls">
|
|
||||||
{/* Time and Duration */}
|
|
||||||
<div className="video-time-display">
|
|
||||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
|
||||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar with enhanced dragging */}
|
|
||||||
<div
|
|
||||||
ref={progressRef}
|
|
||||||
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
|
|
||||||
onClick={handleProgressClick}
|
|
||||||
onMouseDown={handleProgressDragStart}
|
|
||||||
onTouchStart={handleProgressTouchStart}
|
|
||||||
>
|
|
||||||
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
|
|
||||||
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
|
|
||||||
|
|
||||||
{/* Floating time tooltip when dragging */}
|
|
||||||
{isDraggingProgress && (
|
|
||||||
<div
|
|
||||||
className="video-time-tooltip"
|
|
||||||
style={{
|
|
||||||
left: `${tooltipPosition.x}px`,
|
|
||||||
transform: "translateX(-50%)"
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{formatDetailedTime(tooltipTime)}
|
<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>
|
||||||
)}
|
|
||||||
</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;
|
export default VideoPlayer;
|
||||||
|
|||||||
@ -3,29 +3,29 @@
|
|||||||
* but always shows errors, warnings, and info messages.
|
* but always shows errors, warnings, and info messages.
|
||||||
*/
|
*/
|
||||||
const logger = {
|
const logger = {
|
||||||
/**
|
/**
|
||||||
* Logs debug messages only in development environment
|
* Logs debug messages only in development environment
|
||||||
*/
|
*/
|
||||||
debug: (...args: any[]) => {
|
debug: (...args: any[]) => {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.debug(...args);
|
console.debug(...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always logs error messages
|
* Always logs error messages
|
||||||
*/
|
*/
|
||||||
error: (...args: any[]) => console.error(...args),
|
error: (...args: any[]) => console.error(...args),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always logs warning messages
|
* Always logs warning messages
|
||||||
*/
|
*/
|
||||||
warn: (...args: any[]) => console.warn(...args),
|
warn: (...args: any[]) => console.warn(...args),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always logs info messages
|
* Always logs info messages
|
||||||
*/
|
*/
|
||||||
info: (...args: any[]) => console.info(...args)
|
info: (...args: any[]) => console.info(...args),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|||||||
@ -1,55 +1,51 @@
|
|||||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
import { QueryClient, QueryFunction } from '@tanstack/react-query';
|
||||||
|
|
||||||
async function throwIfResNotOk(res: Response) {
|
async function throwIfResNotOk(res: Response) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = (await res.text()) || res.statusText;
|
const text = (await res.text()) || res.statusText;
|
||||||
throw new Error(`${res.status}: ${text}`);
|
throw new Error(`${res.status}: ${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest(
|
export async function apiRequest(method: string, url: string, data?: unknown | undefined): Promise<Response> {
|
||||||
method: string,
|
const res = await fetch(url, {
|
||||||
url: string,
|
method,
|
||||||
data?: unknown | undefined
|
headers: data ? { 'Content-Type': 'application/json' } : {},
|
||||||
): Promise<Response> {
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
const res = await fetch(url, {
|
credentials: 'include',
|
||||||
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);
|
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({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
queryFn: getQueryFn({ on401: "throw" }),
|
queryFn: getQueryFn({ on401: 'throw' }),
|
||||||
refetchInterval: false,
|
refetchInterval: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
retry: false
|
retry: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mutations: {
|
|
||||||
retry: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,33 +2,33 @@
|
|||||||
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||||
*/
|
*/
|
||||||
export const formatDetailedTime = (seconds: number): string => {
|
export const formatDetailedTime = (seconds: number): string => {
|
||||||
if (isNaN(seconds)) return "00:00:00.000";
|
if (isNaN(seconds)) return '00:00:00.000';
|
||||||
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||||
|
|
||||||
const formattedHours = String(hours).padStart(2, "0");
|
const formattedHours = String(hours).padStart(2, '0');
|
||||||
const formattedMinutes = String(minutes).padStart(2, "0");
|
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||||
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
|
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
|
||||||
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
|
const formattedMilliseconds = String(milliseconds).padStart(3, '0');
|
||||||
|
|
||||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||||
*/
|
*/
|
||||||
export const formatTime = (seconds: number): string => {
|
export const formatTime = (seconds: number): string => {
|
||||||
// Use the detailed format instead of the old MM:SS format
|
// Use the detailed format instead of the old MM:SS format
|
||||||
return formatDetailedTime(seconds);
|
return formatDetailedTime(seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||||
*/
|
*/
|
||||||
export const formatLongTime = (seconds: number): string => {
|
export const formatLongTime = (seconds: number): string => {
|
||||||
// Use the detailed format
|
// Use the detailed format
|
||||||
return formatDetailedTime(seconds);
|
return formatDetailedTime(seconds);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,45 +3,42 @@
|
|||||||
* Returns a CSS color based on the segment position
|
* Returns a CSS color based on the segment position
|
||||||
*/
|
*/
|
||||||
export const generateSolidColor = (time: number, duration: number): string => {
|
export const generateSolidColor = (time: number, duration: number): string => {
|
||||||
// Use the time position to create different colors
|
// Use the time position to create different colors
|
||||||
// This gives each segment a different color without needing an image
|
// This gives each segment a different color without needing an image
|
||||||
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
|
||||||
|
|
||||||
// Calculate color based on position
|
// Calculate color based on position
|
||||||
// Use an extremely light blue-based color palette
|
// Use an extremely light blue-based color palette
|
||||||
const hue = 210; // Blue base
|
const hue = 210; // Blue base
|
||||||
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
|
||||||
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
|
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
|
* Legacy function kept for compatibility
|
||||||
* Now returns a data URL for a solid color square instead of a video thumbnail
|
* Now returns a data URL for a solid color square instead of a video thumbnail
|
||||||
*/
|
*/
|
||||||
export const generateThumbnail = async (
|
export const generateThumbnail = async (videoElement: HTMLVideoElement, time: number): Promise<string> => {
|
||||||
videoElement: HTMLVideoElement,
|
return new Promise((resolve) => {
|
||||||
time: number
|
// Create a small canvas for the solid color
|
||||||
): Promise<string> => {
|
const canvas = document.createElement('canvas');
|
||||||
return new Promise((resolve) => {
|
canvas.width = 10; // Much smaller - we only need a color
|
||||||
// Create a small canvas for the solid color
|
canvas.height = 10;
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = 10; // Much smaller - we only need a color
|
|
||||||
canvas.height = 10;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// Get the solid color based on time
|
// Get the solid color based on time
|
||||||
const color = generateSolidColor(time, videoElement.duration);
|
const color = generateSolidColor(time, videoElement.duration);
|
||||||
|
|
||||||
// Fill with solid color
|
// Fill with solid color
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to data URL (much smaller now)
|
// Convert to data URL (much smaller now)
|
||||||
const dataUrl = canvas.toDataURL("image/png", 0.5);
|
const dataUrl = canvas.toDataURL('image/png', 0.5);
|
||||||
resolve(dataUrl);
|
resolve(dataUrl);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import "./index.css";
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.MEDIA_DATA = {
|
window.MEDIA_DATA = {
|
||||||
videoUrl: "",
|
videoUrl: "",
|
||||||
mediaId: ""
|
mediaId: "",
|
||||||
|
posterUrl: ""
|
||||||
};
|
};
|
||||||
window.lastSeekedPosition = 0;
|
window.lastSeekedPosition = 0;
|
||||||
}
|
}
|
||||||
@ -15,6 +16,7 @@ declare global {
|
|||||||
MEDIA_DATA: {
|
MEDIA_DATA: {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
|
posterUrl?: string;
|
||||||
};
|
};
|
||||||
seekToFunction?: (time: number) => void;
|
seekToFunction?: (time: number) => void;
|
||||||
lastSeekedPosition: number;
|
lastSeekedPosition: number;
|
||||||
|
|||||||
@ -1,111 +1,195 @@
|
|||||||
// API service for video trimming operations
|
// API service for video trimming operations
|
||||||
|
import logger from '../lib/logger';
|
||||||
interface TrimVideoRequest {
|
interface TrimVideoRequest {
|
||||||
segments: {
|
segments: {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}[];
|
}[];
|
||||||
saveAsCopy?: boolean;
|
saveAsCopy?: boolean;
|
||||||
saveIndividualSegments?: boolean;
|
saveIndividualSegments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrimVideoResponse {
|
interface TrimVideoResponse {
|
||||||
msg: string;
|
msg: string;
|
||||||
url_redirect: string;
|
url_redirect: string;
|
||||||
status?: number; // HTTP status code for success/error
|
status?: number; // HTTP status code for success/error
|
||||||
error?: string; // Error message if status is not 200
|
error?: string; // Error message if status is not 200
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to simulate delay
|
// Helper function to simulate delay
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
// For now, we'll use a mock API that returns a promise
|
// Auto-save interface
|
||||||
// This can be replaced with actual API calls later
|
interface AutoSaveRequest {
|
||||||
export const trimVideo = async (
|
segments: {
|
||||||
mediaId: string,
|
startTime: string;
|
||||||
data: TrimVideoRequest
|
endTime: string;
|
||||||
): Promise<TrimVideoResponse> => {
|
name?: string;
|
||||||
try {
|
}[];
|
||||||
// Attempt the real API call
|
}
|
||||||
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
interface AutoSaveResponse {
|
||||||
// For error responses, return with error status and message
|
success: boolean;
|
||||||
if (response.status === 400) {
|
timestamp: string;
|
||||||
// Handle 400 Bad Request - return with error details
|
error?: string;
|
||||||
try {
|
status?: string;
|
||||||
// Try to get error details from response
|
media_id?: string;
|
||||||
const errorData = await response.json();
|
segments?: {
|
||||||
return {
|
startTime: string;
|
||||||
status: 400,
|
endTime: string;
|
||||||
error: errorData.error || "An error occurred during processing",
|
name: string;
|
||||||
msg: "Video Processing Error",
|
}[];
|
||||||
url_redirect: ""
|
updated_at?: string;
|
||||||
};
|
}
|
||||||
} catch (parseError) {
|
|
||||||
// If can't parse response JSON, return generic error
|
// Auto-save API function
|
||||||
return {
|
export const autoSaveVideo = async (mediaId: string, data: AutoSaveRequest): Promise<AutoSaveResponse> => {
|
||||||
status: 400,
|
try {
|
||||||
error: "An error occurred during video processing",
|
const response = await fetch(`/api/v1/media/${mediaId}/save_trim`, {
|
||||||
msg: "Video Processing Error",
|
method: 'POST',
|
||||||
url_redirect: ""
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('response', response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// For error responses, return with error status
|
||||||
|
if (response.status === 404) {
|
||||||
|
// If endpoint not ready (404), return mock success response
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
timestamp: timestamp,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Handle other error responses
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: errorData.error || 'Auto-save failed',
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: 'Auto-save failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (response.status !== 404) {
|
|
||||||
// Handle other error responses
|
// Successful response
|
||||||
try {
|
const jsonResponse = await response.json();
|
||||||
// Try to get error details from response
|
|
||||||
const errorData = await response.json();
|
// Check if the response has the expected format
|
||||||
return {
|
if (jsonResponse.status === 'success') {
|
||||||
status: response.status,
|
return {
|
||||||
error: errorData.error || "An error occurred during processing",
|
success: true,
|
||||||
msg: "Video Processing Error",
|
timestamp: jsonResponse.updated_at || new Date().toISOString(),
|
||||||
url_redirect: ""
|
...jsonResponse,
|
||||||
};
|
};
|
||||||
} catch (parseError) {
|
} else {
|
||||||
// If can't parse response JSON, return generic error
|
return {
|
||||||
return {
|
success: false,
|
||||||
status: response.status,
|
timestamp: new Date().toISOString(),
|
||||||
error: "An error occurred during video processing",
|
error: jsonResponse.error || 'Auto-save failed',
|
||||||
msg: "Video Processing Error",
|
};
|
||||||
url_redirect: ""
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
// If endpoint not ready (404), return mock success response
|
// For any fetch errors, return mock success response
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
timestamp: timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trimVideo = async (mediaId: string, data: TrimVideoRequest): Promise<TrimVideoResponse> => {
|
||||||
|
try {
|
||||||
|
// Attempt the real API call
|
||||||
|
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// For error responses, return with error status and message
|
||||||
|
if (response.status === 400) {
|
||||||
|
// Handle 400 Bad Request - return with error details
|
||||||
|
try {
|
||||||
|
// Try to get error details from response
|
||||||
|
const errorData = await response.json();
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: errorData.error || 'An error occurred during processing',
|
||||||
|
msg: 'Video Processing Error',
|
||||||
|
url_redirect: '',
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
// If can't parse response JSON, return generic error
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'An error occurred during video processing',
|
||||||
|
msg: 'Video Processing Error',
|
||||||
|
url_redirect: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (response.status !== 404) {
|
||||||
|
// Handle other error responses
|
||||||
|
try {
|
||||||
|
// Try to get error details from response
|
||||||
|
const errorData = await response.json();
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
error: errorData.error || 'An error occurred during processing',
|
||||||
|
msg: 'Video Processing Error',
|
||||||
|
url_redirect: '',
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
// If can't parse response JSON, return generic error
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
error: 'An error occurred during video processing',
|
||||||
|
msg: 'Video Processing Error',
|
||||||
|
url_redirect: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If endpoint not ready (404), return mock success response
|
||||||
|
await delay(1500); // Simulate 1.5 second server delay
|
||||||
|
return {
|
||||||
|
status: 200, // Mock success status
|
||||||
|
msg: 'Video Processed Successfully', // Updated per requirements
|
||||||
|
url_redirect: `./view?m=${mediaId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful response
|
||||||
|
const jsonResponse = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
msg: 'Video Processed Successfully', // Ensure the success message is correct
|
||||||
|
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
|
||||||
|
...jsonResponse,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// For any fetch errors, return mock success response with delay
|
||||||
await delay(1500); // Simulate 1.5 second server delay
|
await delay(1500); // Simulate 1.5 second server delay
|
||||||
return {
|
return {
|
||||||
status: 200, // Mock success status
|
status: 200, // Mock success status
|
||||||
msg: "Video Processed Successfully", // Updated per requirements
|
msg: 'Video Processed Successfully', // Consistent with requirements
|
||||||
url_redirect: `./view?m=${mediaId}`
|
url_redirect: `./view?m=${mediaId}`,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful response
|
/* Mock implementation that simulates network latency
|
||||||
const jsonResponse = await response.json();
|
|
||||||
return {
|
|
||||||
status: 200,
|
|
||||||
msg: "Video Processed Successfully", // Ensure the success message is correct
|
|
||||||
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
|
|
||||||
...jsonResponse
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// For any fetch errors, return mock success response with delay
|
|
||||||
await delay(1500); // Simulate 1.5 second server delay
|
|
||||||
return {
|
|
||||||
status: 200, // Mock success status
|
|
||||||
msg: "Video Processed Successfully", // Consistent with requirements
|
|
||||||
url_redirect: `./view?m=${mediaId}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mock implementation that simulates network latency
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve({
|
resolve({
|
||||||
|
|||||||
@ -1,196 +1,196 @@
|
|||||||
#video-editor-trim-root {
|
#video-editor-trim-root {
|
||||||
/* Tooltip styles - only on desktop where hover is available */
|
/* Tooltip styles - only on desktop where hover is available */
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
[data-tooltip] {
|
[data-tooltip] {
|
||||||
position: relative;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:before {
|
/* Hide button tooltips on touch devices */
|
||||||
content: attr(data-tooltip);
|
@media (pointer: coarse) {
|
||||||
position: absolute;
|
[data-tooltip]:before,
|
||||||
bottom: 100%;
|
[data-tooltip]:after {
|
||||||
left: 50%;
|
display: none !important;
|
||||||
transform: translateX(-50%);
|
content: none !important;
|
||||||
margin-bottom: 5px;
|
opacity: 0 !important;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
visibility: hidden !important;
|
||||||
color: white;
|
pointer-events: none !important;
|
||||||
text-align: center;
|
}
|
||||||
padding: 5px 10px;
|
}
|
||||||
border-radius: 3px;
|
.clip-segments-container {
|
||||||
font-size: 12px;
|
margin-top: 1rem;
|
||||||
white-space: nowrap;
|
background-color: white;
|
||||||
opacity: 0;
|
border-radius: 0.5rem;
|
||||||
visibility: hidden;
|
padding: 1rem;
|
||||||
transition:
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
opacity 0.2s,
|
|
||||||
visibility 0.2s;
|
|
||||||
z-index: 1000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:after {
|
.clip-segments-title {
|
||||||
content: "";
|
font-size: 0.875rem;
|
||||||
position: absolute;
|
font-weight: 500;
|
||||||
bottom: 100%;
|
color: var(--foreground, #333);
|
||||||
left: 50%;
|
margin-bottom: 0.75rem;
|
||||||
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,
|
.segment-item {
|
||||||
[data-tooltip]:hover:after {
|
display: flex;
|
||||||
opacity: 1;
|
align-items: center;
|
||||||
visibility: visible;
|
justify-content: space-between;
|
||||||
}
|
padding: 0.5rem;
|
||||||
}
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
/* Hide button tooltips on touch devices */
|
&:hover {
|
||||||
@media (pointer: coarse) {
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
[data-tooltip]:before,
|
}
|
||||||
[data-tooltip]:after {
|
|
||||||
display: none !important;
|
|
||||||
content: none !important;
|
|
||||||
opacity: 0 !important;
|
|
||||||
visibility: hidden !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clip-segments-container {
|
|
||||||
margin-top: 1rem;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clip-segments-title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--foreground, #333);
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
transition: box-shadow 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-thumbnail {
|
|
||||||
width: 4rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-title {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-time {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-duration {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.segment-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete-button {
|
|
||||||
padding: 0.375rem;
|
|
||||||
color: #4b5563;
|
|
||||||
background-color: #e5e7eb;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color 0.2s,
|
|
||||||
color 0.2s;
|
|
||||||
min-width: auto;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: black;
|
|
||||||
background-color: #d1d5db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
.segment-content {
|
||||||
height: 1rem;
|
display: flex;
|
||||||
width: 1rem;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.empty-message {
|
.segment-thumbnail {
|
||||||
padding: 1rem;
|
width: 4rem;
|
||||||
text-align: center;
|
height: 2.25rem;
|
||||||
color: rgba(51, 51, 51, 0.7);
|
background-size: cover;
|
||||||
}
|
background-position: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.segment-color-1 {
|
.segment-info {
|
||||||
background-color: rgba(59, 130, 246, 0.15);
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
.segment-color-2 {
|
}
|
||||||
background-color: rgba(16, 185, 129, 0.15);
|
|
||||||
}
|
.segment-title {
|
||||||
.segment-color-3 {
|
font-weight: 500;
|
||||||
background-color: rgba(245, 158, 11, 0.15);
|
font-size: 0.875rem;
|
||||||
}
|
color: black;
|
||||||
.segment-color-4 {
|
}
|
||||||
background-color: rgba(239, 68, 68, 0.15);
|
|
||||||
}
|
.segment-time {
|
||||||
.segment-color-5 {
|
font-size: 0.75rem;
|
||||||
background-color: rgba(139, 92, 246, 0.15);
|
color: black;
|
||||||
}
|
}
|
||||||
.segment-color-6 {
|
|
||||||
background-color: rgba(236, 72, 153, 0.15);
|
.segment-duration {
|
||||||
}
|
font-size: 0.75rem;
|
||||||
.segment-color-7 {
|
margin-top: 0.25rem;
|
||||||
background-color: rgba(6, 182, 212, 0.15);
|
display: inline-block;
|
||||||
}
|
background-color: #f3f4f6;
|
||||||
.segment-color-8 {
|
padding: 0 0.5rem;
|
||||||
background-color: rgba(250, 204, 21, 0.15);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,397 +1,397 @@
|
|||||||
#video-editor-trim-root {
|
#video-editor-trim-root {
|
||||||
/* Tooltip styles - only on desktop where hover is available */
|
/* Tooltip styles - only on desktop where hover is available */
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
[data-tooltip] {
|
[data-tooltip] {
|
||||||
position: relative;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-tooltip]:before {
|
/* Hide button tooltips on touch devices */
|
||||||
content: attr(data-tooltip);
|
@media (pointer: coarse) {
|
||||||
position: absolute;
|
[data-tooltip]:before,
|
||||||
bottom: 100%;
|
[data-tooltip]:after {
|
||||||
left: 50%;
|
display: none !important;
|
||||||
transform: translateX(-50%);
|
content: none !important;
|
||||||
margin-bottom: 5px;
|
opacity: 0 !important;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
visibility: hidden !important;
|
||||||
color: white;
|
pointer-events: none !important;
|
||||||
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 {
|
.editing-tools-container {
|
||||||
padding: 0.75rem;
|
background-color: white;
|
||||||
overflow-x: hidden;
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* At this breakpoint, make preview button text shorter */
|
.flex-container {
|
||||||
.preview-button {
|
display: flex;
|
||||||
min-width: auto;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
gap: 15px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Switch to short text versions */
|
.flex-container.single-row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show full text on larger screens, hide short text */
|
||||||
.full-text {
|
.full-text {
|
||||||
display: none;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-text {
|
.short-text {
|
||||||
display: inline;
|
display: none;
|
||||||
margin-left: 0.15rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide reset text */
|
/* Reset text always visible by default */
|
||||||
.reset-text {
|
.reset-text {
|
||||||
display: none;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.button-group {
|
||||||
max-width: 50%;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group.play-buttons-group {
|
.divider {
|
||||||
max-width: 60%;
|
border-right: 1px solid #d1d5db;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group.secondary {
|
/* Style for play buttons with highlight effect */
|
||||||
max-width: 40%;
|
.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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,167 +1,167 @@
|
|||||||
.ios-notification {
|
.ios-notification {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background-color: #fffdeb;
|
background-color: #fffdeb;
|
||||||
border-bottom: 1px solid #e2e2e2;
|
border-bottom: 1px solid #e2e2e2;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
animation: slide-down 0.5s ease-in-out;
|
animation: slide-down 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-down {
|
@keyframes slide-down {
|
||||||
from {
|
from {
|
||||||
transform: translateY(-100%);
|
transform: translateY(-100%);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-content {
|
.ios-notification-content {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-icon {
|
.ios-notification-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #0066cc;
|
color: #0066cc;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message {
|
.ios-notification-message {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message h3 {
|
.ios-notification-message h3 {
|
||||||
margin: 0 0 5px 0;
|
margin: 0 0 5px 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message p {
|
.ios-notification-message p {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message ol {
|
.ios-notification-message ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message li {
|
.ios-notification-message li {
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-close {
|
.ios-notification-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #666;
|
color: #666;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-close:hover {
|
.ios-notification-close:hover {
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop mode button styling */
|
/* Desktop mode button styling */
|
||||||
.ios-mode-options {
|
.ios-mode-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-desktop-mode-btn {
|
.ios-desktop-mode-btn {
|
||||||
background-color: #0066cc;
|
background-color: #0066cc;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-desktop-mode-btn:hover {
|
.ios-desktop-mode-btn:hover {
|
||||||
background-color: #0055aa;
|
background-color: #0055aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-desktop-mode-btn:active {
|
.ios-desktop-mode-btn:active {
|
||||||
background-color: #004499;
|
background-color: #004499;
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-or {
|
.ios-or {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iOS-specific styles */
|
/* iOS-specific styles */
|
||||||
@supports (-webkit-touch-callout: none) {
|
@supports (-webkit-touch-callout: none) {
|
||||||
.ios-notification {
|
.ios-notification {
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-close {
|
.ios-notification-close {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make sure this notification has better visibility on smaller screens */
|
/* Make sure this notification has better visibility on smaller screens */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.ios-notification-content {
|
.ios-notification-content {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message h3 {
|
.ios-notification-message h3 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-notification-message p,
|
.ios-notification-message p,
|
||||||
.ios-notification-message ol {
|
.ios-notification-message ol {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add iOS-specific styles when in desktop mode */
|
/* Add iOS-specific styles when in desktop mode */
|
||||||
html.ios-device {
|
html.ios-device {
|
||||||
/* Force the content to be rendered at desktop width */
|
/* Force the content to be rendered at desktop width */
|
||||||
min-width: 1024px;
|
min-width: 1024px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.ios-device .ios-control-btn {
|
html.ios-device .ios-control-btn {
|
||||||
/* Make buttons easier to tap in desktop mode */
|
/* Make buttons easier to tap in desktop mode */
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,96 +1,96 @@
|
|||||||
.mobile-play-prompt-overlay {
|
.mobile-play-prompt-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
-webkit-backdrop-filter: blur(5px);
|
-webkit-backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-prompt {
|
.mobile-play-prompt {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-prompt h3 {
|
.mobile-play-prompt h3 {
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-prompt p {
|
.mobile-play-prompt p {
|
||||||
margin: 0 0 15px 0;
|
margin: 0 0 15px 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #444;
|
color: #444;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-prompt-instructions {
|
.mobile-prompt-instructions {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-prompt-instructions p {
|
.mobile-prompt-instructions p {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-prompt-instructions ol {
|
.mobile-prompt-instructions ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 22px;
|
padding-left: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-prompt-instructions li {
|
.mobile-prompt-instructions li {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-button {
|
.mobile-play-button {
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 25px;
|
padding: 12px 25px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
/* Make button easier to tap on mobile */
|
/* Make button easier to tap on mobile */
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-button:hover {
|
.mobile-play-button:hover {
|
||||||
background-color: #0069d9;
|
background-color: #0069d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-play-button:active {
|
.mobile-play-button:active {
|
||||||
background-color: #0062cc;
|
background-color: #0062cc;
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special styles for mobile devices */
|
/* Special styles for mobile devices */
|
||||||
@supports (-webkit-touch-callout: none) {
|
@supports (-webkit-touch-callout: none) {
|
||||||
.mobile-play-button {
|
.mobile-play-button {
|
||||||
/* Extra spacing for mobile */
|
/* Extra spacing for mobile */
|
||||||
padding: 14px 25px;
|
padding: 14px 25px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,94 +1,94 @@
|
|||||||
.ios-video-player-container {
|
.ios-video-player-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
border: 1px solid #e2e2e2;
|
border: 1px solid #e2e2e2;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-video-player-container video {
|
.ios-video-player-container video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-time-display {
|
.ios-time-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-note {
|
.ios-note {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #777;
|
color: #777;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iOS-specific styling tweaks */
|
/* iOS-specific styling tweaks */
|
||||||
@supports (-webkit-touch-callout: none) {
|
@supports (-webkit-touch-callout: none) {
|
||||||
.ios-video-player-container video {
|
.ios-video-player-container video {
|
||||||
max-height: 50vh; /* Use viewport height on iOS */
|
max-height: 50vh; /* Use viewport height on iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improve controls visibility on iOS */
|
/* Improve controls visibility on iOS */
|
||||||
video::-webkit-media-controls {
|
video::-webkit-media-controls {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
visibility: visible !important;
|
visibility: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure controls don't disappear too quickly */
|
/* Ensure controls don't disappear too quickly */
|
||||||
video::-webkit-media-controls-panel {
|
video::-webkit-media-controls-panel {
|
||||||
transition-duration: 3s !important;
|
transition-duration: 3s !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* External controls styling */
|
/* External controls styling */
|
||||||
.ios-external-controls {
|
.ios-external-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-control-btn {
|
.ios-control-btn {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
height: 44px; /* Minimum touch target size for iOS */
|
height: 44px; /* Minimum touch target size for iOS */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
|
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-control-btn:active {
|
.ios-control-btn:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent text selection on buttons */
|
/* Prevent text selection on buttons */
|
||||||
.no-select {
|
.no-select {
|
||||||
-webkit-touch-callout: none; /* iOS Safari */
|
-webkit-touch-callout: none; /* iOS Safari */
|
||||||
-webkit-user-select: none; /* Safari */
|
-webkit-user-select: none; /* Safari */
|
||||||
-khtml-user-select: none; /* Konqueror HTML */
|
-khtml-user-select: none; /* Konqueror HTML */
|
||||||
-moz-user-select: none; /* Firefox */
|
-moz-user-select: none; /* Firefox */
|
||||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||||
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
user-select: none; /* Non-prefixed version, supported by Chrome and Opera */
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Specifically prevent default behavior on fine controls */
|
/* Specifically prevent default behavior on fine controls */
|
||||||
.ios-fine-controls button,
|
.ios-fine-controls button,
|
||||||
.ios-external-controls .no-select {
|
.ios-external-controls .no-select {
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,306 +1,306 @@
|
|||||||
#video-editor-trim-root {
|
#video-editor-trim-root {
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
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 {
|
.modal-container {
|
||||||
width: 95%;
|
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 {
|
.modal-actions {
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button {
|
.modal-button {
|
||||||
width: 100%;
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
.modal-button-primary {
|
||||||
color: #f44336;
|
background-color: #0066cc;
|
||||||
font-weight: 500;
|
color: white;
|
||||||
background-color: rgba(244, 67, 54, 0.1);
|
}
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 4px solid #f44336;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redirect-message {
|
.modal-button-primary:hover {
|
||||||
margin-top: 20px;
|
background-color: #0055aa;
|
||||||
color: #555;
|
}
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown {
|
.modal-button-secondary {
|
||||||
font-weight: bold;
|
background-color: #f0f0f0;
|
||||||
color: #0066cc;
|
color: #333;
|
||||||
font-size: 1.1rem;
|
}
|
||||||
}
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||