Compare commits

...

9 Commits

Author SHA1 Message Date
Markos Gogoulos
df98b65704 feat: pass version on static files (#1318) 2025-07-07 11:54:02 +03:00
Markos Gogoulos
a607996bfa feat: adds minimum resolution of 144p 2025-07-07 11:34:02 +03:00
Markos Gogoulos
79f2e2bb11 feat: replace format with fstrings 2025-07-07 11:26:08 +03:00
Markos Gogoulos
d54732040a feat: add DB connection pooling 2025-07-07 11:18:40 +03:00
Andy
e8520bc7cd fix: date picker in edit media (#1297)
By default, Django uses type=text for the date input, which respects
the locale settings. However, when changing the input type to date,
it should only accept YYYY-MM-DD format so the input field can be
properly handled.
2025-07-06 11:44:44 +03:00
Markos Gogoulos
b6e46e7b62 feat: replace login middleware (#1314) 2025-07-06 11:25:50 +03:00
Adam Stradovnik
36eab954bd feat: Adds support for Slovenian frontend translations (#1306) 2025-07-06 11:05:07 +03:00
Markos Gogoulos
610716533b fix formatting 2025-07-01 15:46:34 +03:00
Yiannis Christodoulou
4f1c4a2b4c fix: Disable Segment Tools and Reset Preview State During Playback (#1305)
* fix: Disable Segment Tools and Reset Preview State During Playback

* chore: remove some unnecessary comments

* chore: build assets

* fix: do not display the handles (left/right) on preview mode

* fix: Disable all tools on preview mode (undo, redo, reset, etc.)

* Update README.md

* feat: Prettier configuration for video editor

* Update README.md

* Update .prettierrc

* style: Format entire codebase (video-editor) with Prettier

* fix: During segments playback mode, disable button interactions but keep hover working

* feat: Add yarn format

* prettier format

* Update package.json

* feat: Install prettier and improve formatting

* build assets

* Update version.py 6.2.0
2025-07-01 15:33:39 +03:00
61 changed files with 3236 additions and 2446 deletions

4
.gitignore vendored
View File

@@ -25,3 +25,7 @@ yt.readme.md
frontend-tools/.DS_Store frontend-tools/.DS_Store
static/video_editor/videos/sample-video-30s.mp4 static/video_editor/videos/sample-video-30s.mp4
static/video_editor/videos/sample-video-37s.mp4 static/video_editor/videos/sample-video-37s.mp4
/frontend-tools/video-editor-v2
.DS_Store
static/video_editor/videos/sample-video-10m.mp4
static/video_editor/videos/sample-video-10s.mp4

View File

@@ -38,7 +38,7 @@ A demo is available at https://demo.mediacms.io
- **Configurable actions**: allow download, add comments, add likes, dislikes, report media - **Configurable actions**: allow download, add comments, add likes, dislikes, report media
- **Configuration options**: change logos, fonts, styling, add more pages - **Configuration options**: change logos, fonts, styling, add more pages
- **Enhanced video player**: customized video.js player with multiple resolution and playback speed options - **Enhanced video player**: customized video.js player with multiple resolution and playback speed options
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9) - **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
- **Adaptive video streaming**: possible through HLS protocol - **Adaptive video streaming**: possible through HLS protocol
- **Subtitles/CC**: support for multilingual subtitle files - **Subtitles/CC**: support for multilingual subtitle files
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers - **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers
@@ -93,20 +93,14 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b). A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
## Configuration
Visit [Configuration](docs/admins_docs.md#5-configuration) page.
## Information for developers
Check out the new section on the [Developer Experience](docs/dev_exp.md) page
## Documentation ## Documentation
* [Users documentation](docs/user_docs.md) page * [Users documentation](docs/user_docs.md) page
* [Administrators documentation](docs/admins_docs.md) page * [Administrators documentation](docs/admins_docs.md) page
* [Developers documentation](docs/developers_docs.md) page * [Developers documentation](docs/developers_docs.md) page
* [Configuration](docs/admins_docs.md#5-configuration) page
* [Transcoding](docs/transcoding.md) page
* [Developer Experience](docs/dev_exp.md) page
## Technology ## Technology

View File

@@ -186,7 +186,7 @@ CHUNKIZE_VIDEO_DURATION = 60 * 5
VIDEO_CHUNKS_DURATION = 60 * 4 VIDEO_CHUNKS_DURATION = 60 * 4
# always get these two, even if upscaling # always get these two, even if upscaling
MINIMUM_RESOLUTIONS_TO_ENCODE = [240, 360] MINIMUM_RESOLUTIONS_TO_ENCODE = [144, 240]
# default settings for notifications # default settings for notifications
# not all of them are implemented # not all of them are implemented
@@ -376,16 +376,7 @@ LOGGING = {
}, },
} }
DATABASES = { DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": "127.0.0.1",
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
}
}
REDIS_LOCATION = "redis://127.0.0.1:6379/1" REDIS_LOCATION = "redis://127.0.0.1:6379/1"
@@ -466,6 +457,7 @@ LANGUAGES = [
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('ru', _('Russian')), ('ru', _('Russian')),
('zh-hans', _('Simplified Chinese')), ('zh-hans', _('Simplified Chinese')),
('sl', _('Slovenian')),
('zh-hant', _('Traditional Chinese')), ('zh-hant', _('Traditional Chinese')),
('es', _('Spanish')), ('es', _('Spanish')),
('tr', _('Turkish')), ('tr', _('Turkish')),
@@ -505,6 +497,10 @@ USE_ROUNDED_CORNERS = True
ALLOW_VIDEO_TRIMMER = True ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
# ffmpeg options
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
try: try:
# keep a local_settings.py file for local overrides # keep a local_settings.py file for local overrides
from .local_settings import * # noqa from .local_settings import * # noqa
@@ -544,13 +540,5 @@ except ImportError:
if GLOBAL_LOGIN_REQUIRED: if GLOBAL_LOGIN_REQUIRED:
# this should go after the AuthenticationMiddleware middleware auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware") MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
LOGIN_REQUIRED_IGNORE_PATHS = [
r'/accounts/login/$',
r'/accounts/logout/$',
r'/accounts/signup/$',
r'/accounts/password/.*/$',
r'/accounts/confirm-email/.*/$',
# r'/api/v[0-9]+/',
]

View File

@@ -1 +1 @@
VERSION = "6.1.0" VERSION = "6.3.0"

View File

@@ -13,6 +13,7 @@ DATABASES = {
"PORT": os.getenv('POSTGRES_PORT', '5432'), "PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'), "USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'), "PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
"OPTIONS": {'pool': True},
} }
} }

View File

@@ -72,7 +72,7 @@ services:
POSTGRES_DB: mediacms POSTGRES_DB: mediacms
TZ: Europe/London TZ: Europe/London
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}", "--host=db", "--dbname=$POSTGRES_DB", "--username=$POSTGRES_USER"] test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -81,6 +81,6 @@ services:
restart: always restart: always
healthcheck: healthcheck:
test: ["CMD", "redis-cli","ping"] test: ["CMD", "redis-cli","ping"]
interval: 30s interval: 10s
timeout: 10s timeout: 5s
retries: 3 retries: 3

50
docs/transcoding.md Normal file
View File

@@ -0,0 +1,50 @@
# Transcoding in MediaCMS
MediaCMS uses FFmpeg for transcoding media files. Most of the transcoding settings and configurations are defined in `files/helpers.py`.
## Configuration Options
Several transcoding parameters can be customized in `cms/settings.py`:
### FFmpeg Preset
The default FFmpeg preset is set to "medium". This setting controls the encoding speed and compression efficiency trade-off.
```python
# ffmpeg options
FFMPEG_DEFAULT_PRESET = "medium" # see https://trac.ffmpeg.org/wiki/Encode/H.264
```
Available presets include:
- ultrafast
- superfast
- veryfast
- faster
- fast
- medium (default)
- slow
- slower
- veryslow
Faster presets result in larger file sizes for the same quality, while slower presets provide better compression but take longer to encode.
### Other Transcoding Settings
Additional transcoding settings in `settings.py` include:
- `FFMPEG_COMMAND`: Path to the FFmpeg executable
- `FFPROBE_COMMAND`: Path to the FFprobe executable
- `DO_NOT_TRANSCODE_VIDEO`: If set to True, only the original video is shown without transcoding
- `CHUNKIZE_VIDEO_DURATION`: For videos longer than this duration (in seconds), they get split into chunks and encoded independently
- `VIDEO_CHUNKS_DURATION`: Duration of each chunk (must be smaller than CHUNKIZE_VIDEO_DURATION)
- `MINIMUM_RESOLUTIONS_TO_ENCODE`: Always encode these resolutions, even if upscaling is required
## Advanced Configuration
For more advanced transcoding settings, you may need to modify the following in `files/helpers.py`:
- Video bitrates for different codecs and resolutions
- Audio encoders and bitrates
- CRF (Constant Rate Factor) values
- Keyframe settings
- Encoding parameters for different codecs (H.264, H.265, VP9)

View File

@@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from cms.version import VERSION
from .frontend_translations import get_translation, get_translation_strings from .frontend_translations import get_translation, get_translation_strings
from .methods import is_mediacms_editor, is_mediacms_manager from .methods import is_mediacms_editor, is_mediacms_manager
@@ -37,6 +39,7 @@ def stuff(request):
ret["USE_SAML"] = settings.USE_SAML ret["USE_SAML"] = settings.USE_SAML
ret["USE_RBAC"] = settings.USE_RBAC ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["VERSION"] = VERSION
if request.user.is_superuser: if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL

View File

@@ -83,7 +83,7 @@ class IndexRSSFeed(Feed):
return item.edit_date return item.edit_date
def item_link(self, item): def item_link(self, item):
return reverse("get_media") + "?m={0}".format(item.friendly_token) return f"{reverse('get_media')}?m={item.friendly_token}"
def item_extra_kwargs(self, item): def item_extra_kwargs(self, item):
item = { item = {
@@ -151,7 +151,7 @@ class SearchRSSFeed(Feed):
return item.edit_date return item.edit_date
def item_link(self, item): def item_link(self, item):
return reverse("get_media") + "?m={0}".format(item.friendly_token) return f"{reverse('get_media')}?m={item.friendly_token}"
def item_extra_kwargs(self, item): def item_extra_kwargs(self, item):
item = { item = {

View File

@@ -35,7 +35,7 @@ class MediaMetadataForm(forms.ModelForm):
widgets = { widgets = {
"new_tags": MultipleSelect(), "new_tags": MultipleSelect(),
"description": forms.Textarea(attrs={'rows': 4}), "description": forms.Textarea(attrs={'rows': 4}),
"add_date": forms.DateInput(attrs={'type': 'date'}), "add_date": forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}), "thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
} }
labels = { labels = {

View File

@@ -0,0 +1,104 @@
translation_strings = {
"ABOUT": "O NAS",
"AUTOPLAY": "SAMODEJNO PREDVAJANJE",
"Add a ": "Dodaj ",
"COMMENT": "KOMENTAR",
"Categories": "Kategorije",
"Category": "Kategorija",
"Change Language": "Spremeni jezik",
"Change password": "Spremeni geslo",
"About": "O nas",
"Comment": "Komentar",
"Comments": "Komentarji",
"Comments are disabled": "Komentarji so onemogočeni",
"Contact": "Kontakt",
"DELETE MEDIA": "IZBRIŠI MEDIJ",
"DOWNLOAD": "PRENESI",
"EDIT MEDIA": "UREDI MEDIJ",
"EDIT PROFILE": "UREDI PROFIL",
"EDIT SUBTITLE": "UREDI PODNAPISE",
"Edit media": "Uredi medij",
"Edit profile": "Uredi profil",
"Edit subtitle": "Uredi podnapise",
"Featured": "Izbrani",
"Go": "Pojdi",
"History": "Zgodovina",
"Home": "Domov",
"Language": "Jezik",
"Latest": "Najnovejši",
"Liked media": "Všečkani mediji",
"Manage comments": "Upravljaj komentarje",
"Manage media": "Upravljaj medije",
"Manage users": "Upravljaj uporabnike",
"Media": "Mediji",
"Media was edited": "Medij je bil urejen",
"Members": "Člani",
"My media": "Moji mediji",
"My playlists": "Moji seznami predvajanja",
"No": "Ne",
"No comment yet": "Brez komentarja",
"No comments yet": "Brez komentarjev",
"No results for": "Ni rezultatov za",
"PLAYLISTS": "SEZNAMI PREDVAJANJA",
"Playlists": "Seznami predvajanja",
"Powered by": "Poganja",
"Published on": "Objavljeno",
"Recommended": "Priporočeno",
"Register": "Registracija",
"SAVE": "SHRANI",
"SEARCH": "ISKANJE",
"SHARE": "DELI",
"SHOW MORE": "PRIKAŽI VEČ",
"SUBMIT": "POŠLJI",
"Search": "Iskanje",
"Select": "Izberi",
"Sign in": "Prijava",
"Sign out": "Odjava",
"Subtitle was added": "Podnapisi so bili dodani",
"Tags": "Oznake",
"Terms": "Pogoji",
"UPLOAD": "NALOŽI",
"Up next": "Naslednji",
"Upload": "Naloži",
"Upload media": "Naloži medij",
"Uploads": "Naloženi",
"VIEW ALL": "PRIKAŽI VSE",
"View all": "Prikaži vse",
"comment": "komentar",
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "je moderni, popolnoma opremljen odprtokodni video in medijski CMS. Razvit je za potrebe sodobnih spletnih platform za ogled in deljenje medijev",
"media in category": "mediji v kategoriji",
"media in tag": "mediji z oznako",
"view": "ogled",
"views": "ogledi",
"yet": "še",
}
replacement_strings = {
"Apr": "Apr",
"Aug": "Avg",
"Dec": "Dec",
"Feb": "Feb",
"Jan": "Jan",
"Jul": "Jul",
"Jun": "Jun",
"Mar": "Mar",
"May": "Maj",
"Nov": "Nov",
"Oct": "Okt",
"Sep": "Sep",
"day ago": "dan nazaj",
"days ago": "dni nazaj",
"hour ago": "ura nazaj",
"hours ago": "ur nazaj",
"just now": "pravkar",
"minute ago": "minuta nazaj",
"minutes ago": "minut nazaj",
"month ago": "mesec nazaj",
"months ago": "mesecev nazaj",
"second ago": "sekunda nazaj",
"seconds ago": "sekund nazaj",
"week ago": "teden nazaj",
"weeks ago": "tednov nazaj",
"year ago": "leto nazaj",
"years ago": "let nazaj",
}

View File

@@ -34,12 +34,6 @@ BUF_SIZE_MULTIPLIER = 1.5
KEYFRAME_DISTANCE = 4 KEYFRAME_DISTANCE = 4
KEYFRAME_DISTANCE_MIN = 2 KEYFRAME_DISTANCE_MIN = 2
# speed presets
# see https://trac.ffmpeg.org/wiki/Encode/H.264
X26x_PRESET = "medium" # "medium"
X265_PRESET = "medium"
X26x_PRESET_BIG_HEIGHT = "faster"
# VP9_SPEED = 1 # between 0 and 4, lower is slower # VP9_SPEED = 1 # between 0 and 4, lower is slower
VP9_SPEED = 2 VP9_SPEED = 2
@@ -55,6 +49,7 @@ VIDEO_CRFS = {
VIDEO_BITRATES = { VIDEO_BITRATES = {
"h264": { "h264": {
25: { 25: {
144: 150,
240: 300, 240: 300,
360: 500, 360: 500,
480: 1000, 480: 1000,
@@ -67,6 +62,7 @@ VIDEO_BITRATES = {
}, },
"h265": { "h265": {
25: { 25: {
144: 75,
240: 150, 240: 150,
360: 275, 360: 275,
480: 500, 480: 500,
@@ -79,6 +75,7 @@ VIDEO_BITRATES = {
}, },
"vp9": { "vp9": {
25: { 25: {
144: 75,
240: 150, 240: 150,
360: 275, 360: 275,
480: 500, 480: 500,
@@ -173,7 +170,7 @@ def rm_dir(directory):
def url_from_path(filename): def url_from_path(filename):
# TODO: find a way to preserver http - https ... # TODO: find a way to preserver http - https ...
return "{0}{1}".format(settings.MEDIA_URL, filename.replace(settings.MEDIA_ROOT, "")) return f"{settings.MEDIA_URL}{filename.replace(settings.MEDIA_ROOT, '')}"
def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY): def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY):
@@ -488,7 +485,7 @@ def show_file_size(size):
if size: if size:
size = size / 1000000 size = size / 1000000
size = round(size, 1) size = round(size, 1)
size = "{0}MB".format(str(size)) size = f"{str(size)}MB"
return size return size
@@ -596,17 +593,13 @@ def get_base_ffmpeg_command(
cmd = base_cmd[:] cmd = base_cmd[:]
# preset settings # preset settings
preset = getattr(settings, "FFMPEG_DEFAULT_PRESET", "medium")
if encoder == "libvpx-vp9": if encoder == "libvpx-vp9":
if pass_number == 1: if pass_number == 1:
speed = 4 speed = 4
else: else:
speed = VP9_SPEED speed = VP9_SPEED
elif encoder in ["libx264"]:
preset = X26x_PRESET
elif encoder in ["libx265"]:
preset = X265_PRESET
if target_height >= 720:
preset = X26x_PRESET_BIG_HEIGHT
if encoder == "libx264": if encoder == "libx264":
level = "4.2" if target_height <= 1080 else "5.2" level = "4.2" if target_height <= 1080 else "5.2"
@@ -730,7 +723,7 @@ def produce_ffmpeg_commands(media_file, media_info, resolution, codec, output_fi
return False return False
if media_info.get("video_height") < resolution: if media_info.get("video_height") < resolution:
if resolution not in [240, 360]: # always get these two if resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
return False return False
# if codec == "h264_baseline": # if codec == "h264_baseline":

View File

@@ -166,14 +166,14 @@ Media becomes private if it gets reported %s times\n
) )
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_REPORTED", False): if settings.ADMINS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
title = "[{}] - Media was reported".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - Media was reported"
d = {} d = {}
d["title"] = title d["title"] = title
d["msg"] = msg d["msg"] = msg
d["to"] = settings.ADMIN_EMAIL_LIST d["to"] = settings.ADMIN_EMAIL_LIST
notify_items.append(d) notify_items.append(d)
if settings.USERS_NOTIFICATIONS.get("MEDIA_REPORTED", False): if settings.USERS_NOTIFICATIONS.get("MEDIA_REPORTED", False):
title = "[{}] - Media was reported".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - Media was reported"
d = {} d = {}
d["title"] = title d["title"] = title
d["msg"] = msg d["msg"] = msg
@@ -182,7 +182,7 @@ Media becomes private if it gets reported %s times\n
if action == "media_added" and media: if action == "media_added" and media:
if settings.ADMINS_NOTIFICATIONS.get("MEDIA_ADDED", False): if settings.ADMINS_NOTIFICATIONS.get("MEDIA_ADDED", False):
title = "[{}] - Media was added".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - Media was added"
msg = """ msg = """
Media %s was added by user %s. Media %s was added by user %s.
""" % ( """ % (
@@ -195,7 +195,7 @@ Media %s was added by user %s.
d["to"] = settings.ADMIN_EMAIL_LIST d["to"] = settings.ADMIN_EMAIL_LIST
notify_items.append(d) notify_items.append(d)
if settings.USERS_NOTIFICATIONS.get("MEDIA_ADDED", False): if settings.USERS_NOTIFICATIONS.get("MEDIA_ADDED", False):
title = "[{}] - Your media was added".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - Your media was added"
msg = """ msg = """
Your media has been added! It will be encoded and will be available soon. Your media has been added! It will be encoded and will be available soon.
URL: %s URL: %s
@@ -339,7 +339,7 @@ def notify_user_on_comment(friendly_token):
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url() media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
if user.notification_on_comments: if user.notification_on_comments:
title = "[{}] - A comment was added".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - A comment was added"
msg = """ msg = """
A comment has been added to your media %s . A comment has been added to your media %s .
View it on %s View it on %s
@@ -363,7 +363,7 @@ def notify_user_on_mention(friendly_token, user_mentioned, cleaned_comment):
media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url() media_url = settings.SSL_FRONTEND_HOST + media.get_absolute_url()
if user.notification_on_comments: if user.notification_on_comments:
title = "[{}] - You were mentioned in a comment".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - You were mentioned in a comment"
msg = """ msg = """
You were mentioned in a comment on %s . You were mentioned in a comment on %s .
View it on %s View it on %s

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-07-05 11:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0009_alter_media_friendly_token'),
]
operations = [
migrations.AlterField(
model_name='encodeprofile',
name='resolution',
field=models.IntegerField(blank=True, choices=[(2160, '2160'), (1440, '1440'), (1080, '1080'), (720, '720'), (480, '480'), (360, '360'), (240, '240'), (144, '144')], null=True),
),
]

View File

@@ -13,7 +13,8 @@ from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files import File from django.core.files import File
from django.db import connection, models from django.db import models
from django.db.models import Func, Value
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -72,6 +73,7 @@ ENCODE_RESOLUTIONS = (
(480, "480"), (480, "480"),
(360, "360"), (360, "360"),
(240, "240"), (240, "240"),
(144, "144"),
) )
CODECS = ( CODECS = (
@@ -90,34 +92,34 @@ def generate_uid():
def original_media_file_path(instance, filename): def original_media_file_path(instance, filename):
"""Helper function to place original media file""" """Helper function to place original media file"""
file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename)) file_name = f"{instance.uid.hex}.{helpers.get_file_name(filename)}"
return settings.MEDIA_UPLOAD_DIR + "user/{0}/{1}".format(instance.user.username, file_name) return settings.MEDIA_UPLOAD_DIR + f"user/{instance.user.username}/{file_name}"
def encoding_media_file_path(instance, filename): def encoding_media_file_path(instance, filename):
"""Helper function to place encoded media file""" """Helper function to place encoded media file"""
file_name = "{0}.{1}".format(instance.media.uid.hex, helpers.get_file_name(filename)) file_name = f"{instance.media.uid.hex}.{helpers.get_file_name(filename)}"
return settings.MEDIA_ENCODING_DIR + "{0}/{1}/{2}".format(instance.profile.id, instance.media.user.username, file_name) return settings.MEDIA_ENCODING_DIR + f"{instance.profile.id}/{instance.media.user.username}/{file_name}"
def original_thumbnail_file_path(instance, filename): def original_thumbnail_file_path(instance, filename):
"""Helper function to place original media thumbnail file""" """Helper function to place original media thumbnail file"""
return settings.THUMBNAIL_UPLOAD_DIR + "user/{0}/{1}".format(instance.user.username, filename) return settings.THUMBNAIL_UPLOAD_DIR + f"user/{instance.user.username}/{filename}"
def subtitles_file_path(instance, filename): def subtitles_file_path(instance, filename):
"""Helper function to place subtitle file""" """Helper function to place subtitle file"""
return settings.SUBTITLES_UPLOAD_DIR + "user/{0}/{1}".format(instance.media.user.username, filename) return settings.SUBTITLES_UPLOAD_DIR + f"user/{instance.media.user.username}/{filename}"
def category_thumb_path(instance, filename): def category_thumb_path(instance, filename):
"""Helper function to place category thumbnail file""" """Helper function to place category thumbnail file"""
file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename)) file_name = f"{instance.uid}.{helpers.get_file_name(filename)}"
return settings.MEDIA_UPLOAD_DIR + "categories/{0}".format(file_name) return settings.MEDIA_UPLOAD_DIR + f"categories/{file_name}"
class Media(models.Model): class Media(models.Model):
@@ -388,8 +390,6 @@ class Media(models.Model):
search field is used to store SearchVector search field is used to store SearchVector
""" """
db_table = self._meta.db_table
# first get anything interesting out of the media # first get anything interesting out of the media
# that needs to be search able # that needs to be search able
@@ -413,19 +413,8 @@ class Media(models.Model):
text = helpers.clean_query(text) text = helpers.clean_query(text)
sql_code = """ Media.objects.filter(id=self.id).update(search=Func(Value('simple'), Value(text), function='to_tsvector'))
UPDATE {db_table} SET search = to_tsvector(
'{config}', '{text}'
) WHERE {db_table}.id = {id}
""".format(
db_table=db_table, config="simple", text=text, id=self.id
)
try:
with connection.cursor() as cursor:
cursor.execute(sql_code)
except BaseException:
pass # TODO:add log
return True return True
def media_init(self): def media_init(self):
@@ -908,7 +897,7 @@ class Media(models.Model):
""" """
res = {} res = {}
valid_resolutions = [240, 360, 480, 720, 1080, 1440, 2160] valid_resolutions = [144, 240, 360, 480, 720, 1080, 1440, 2160]
if self.hls_file: if self.hls_file:
if os.path.exists(self.hls_file): if os.path.exists(self.hls_file):
hls_file = self.hls_file hls_file = self.hls_file
@@ -925,7 +914,7 @@ class Media(models.Model):
if resolution not in valid_resolutions: if resolution not in valid_resolutions:
resolution = iframe_playlist.iframe_stream_info.resolution[0] resolution = iframe_playlist.iframe_stream_info.resolution[0]
res["{}_iframe".format(resolution)] = helpers.url_from_path(uri) res[f"{resolution}_iframe"] = helpers.url_from_path(uri)
for playlist in m3u8_obj.playlists: for playlist in m3u8_obj.playlists:
uri = os.path.join(p, playlist.uri) uri = os.path.join(p, playlist.uri)
if os.path.exists(uri): if os.path.exists(uri):
@@ -934,7 +923,8 @@ class Media(models.Model):
if resolution not in valid_resolutions: if resolution not in valid_resolutions:
resolution = playlist.stream_info.resolution[0] resolution = playlist.stream_info.resolution[0]
res["{}_playlist".format(resolution)] = helpers.url_from_path(uri) res[f"{resolution}_playlist"] = helpers.url_from_path(uri)
return res return res
@property @property
@@ -953,11 +943,11 @@ class Media(models.Model):
def get_absolute_url(self, api=False, edit=False): def get_absolute_url(self, api=False, edit=False):
if edit: if edit:
return reverse("edit_media") + "?m={0}".format(self.friendly_token) return f"{reverse('edit_media')}?m={self.friendly_token}"
if api: if api:
return reverse("api_get_media", kwargs={"friendly_token": self.friendly_token}) return reverse("api_get_media", kwargs={"friendly_token": self.friendly_token})
else: else:
return reverse("get_media") + "?m={0}".format(self.friendly_token) return f"{reverse('get_media')}?m={self.friendly_token}"
@property @property
def edit_url(self): def edit_url(self):
@@ -965,7 +955,7 @@ class Media(models.Model):
@property @property
def add_subtitle_url(self): def add_subtitle_url(self):
return "/add_subtitle?m=%s" % self.friendly_token return f"/add_subtitle?m={self.friendly_token}"
@property @property
def ratings_info(self): def ratings_info(self):
@@ -1060,7 +1050,7 @@ class Category(models.Model):
verbose_name_plural = "Categories" verbose_name_plural = "Categories"
def get_absolute_url(self): def get_absolute_url(self):
return reverse("search") + "?c={0}".format(self.title) return f"{reverse('search')}?c={self.title}"
def update_category_media(self): def update_category_media(self):
"""Set media_count""" """Set media_count"""
@@ -1122,7 +1112,7 @@ class Tag(models.Model):
ordering = ["title"] ordering = ["title"]
def get_absolute_url(self): def get_absolute_url(self):
return reverse("search") + "?t={0}".format(self.title) return f"{reverse('search')}?t={self.title}"
def update_tag_media(self): def update_tag_media(self):
self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count() self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count()
@@ -1261,7 +1251,7 @@ class Encoding(models.Model):
return False return False
def __str__(self): def __str__(self):
return "{0}-{1}".format(self.profile.name, self.media.title) return f"{self.profile.name}-{self.media.title}"
def get_absolute_url(self): def get_absolute_url(self):
return reverse("api_get_encoding", kwargs={"encoding_id": self.id}) return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
@@ -1280,7 +1270,7 @@ class Language(models.Model):
ordering = ["id"] ordering = ["id"]
def __str__(self): def __str__(self):
return "{0}-{1}".format(self.code, self.title) return f"{self.code}-{self.title}"
class Subtitle(models.Model): class Subtitle(models.Model):
@@ -1303,7 +1293,7 @@ class Subtitle(models.Model):
ordering = ["language__title"] ordering = ["language__title"]
def __str__(self): def __str__(self):
return "{0}-{1}".format(self.media.title, self.language.title) return f"{self.media.title}-{self.language.title}"
def get_absolute_url(self): def get_absolute_url(self):
return f"{reverse('edit_subtitle')}?id={self.id}" return f"{reverse('edit_subtitle')}?id={self.id}"
@@ -1347,7 +1337,7 @@ class RatingCategory(models.Model):
verbose_name_plural = "Rating Categories" verbose_name_plural = "Rating Categories"
def __str__(self): def __str__(self):
return "{0}".format(self.title) return f"{self.title}"
def validate_rating(value): def validate_rating(value):
@@ -1376,7 +1366,7 @@ class Rating(models.Model):
unique_together = ("user", "media", "rating_category") unique_together = ("user", "media", "rating_category")
def __str__(self): def __str__(self):
return "{0}, rate for {1} for category {2}".format(self.user.username, self.media.title, self.rating_category.title) return f"{self.user.username}, rate for {self.media.title} for category {self.rating_category.title}"
class Playlist(models.Model): class Playlist(models.Model):
@@ -1488,7 +1478,7 @@ class Comment(MPTTModel):
order_insertion_by = ["add_date"] order_insertion_by = ["add_date"]
def __str__(self): def __str__(self):
return "On {0} by {1}".format(self.media.title, self.user.username) return f"On {self.media.title} by {self.user.username}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
strip_text_items = ["text"] strip_text_items = ["text"]
@@ -1501,7 +1491,7 @@ class Comment(MPTTModel):
super(Comment, self).save(*args, **kwargs) super(Comment, self).save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("get_media") + "?m={0}".format(self.media.friendly_token) return f"{reverse('get_media')}?m={self.media.friendly_token}"
@property @property
def media_url(self): def media_url(self):
@@ -1720,10 +1710,10 @@ def encoding_file_save(sender, instance, created, **kwargs):
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir) seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir)
tf = helpers.create_temp_file(suffix=".{0}".format(instance.profile.extension), dir=temp_dir) tf = helpers.create_temp_file(suffix=f".{instance.profile.extension}", dir=temp_dir)
with open(seg_file, "w") as ff: with open(seg_file, "w") as ff:
for f in chunks_paths: for f in chunks_paths:
ff.write("file {}\n".format(f)) ff.write(f"file {f}\n")
cmd = [ cmd = [
settings.FFMPEG_COMMAND, settings.FFMPEG_COMMAND,
"-y", "-y",
@@ -1750,7 +1740,7 @@ def encoding_file_save(sender, instance, created, **kwargs):
progress=100, progress=100,
) )
all_logs = "\n".join([st.logs for st in chunks]) all_logs = "\n".join([st.logs for st in chunks])
encoding.logs = "{0}\n{1}\n{2}".format(chunks_paths, stdout, all_logs) encoding.logs = f"{chunks_paths}\n{stdout}\n{all_logs}"
workers = list(set([st.worker for st in chunks])) workers = list(set([st.worker for st in chunks]))
encoding.worker = json.dumps({"workers": workers}) encoding.worker = json.dumps({"workers": workers})
@@ -1761,10 +1751,7 @@ def encoding_file_save(sender, instance, created, **kwargs):
with open(tf, "rb") as f: with open(tf, "rb") as f:
myfile = File(f) myfile = File(f)
output_name = "{0}.{1}".format( output_name = f"{helpers.get_file_name(instance.media.media_file.path)}.{instance.profile.extension}"
helpers.get_file_name(instance.media.media_file.path),
instance.profile.extension,
)
encoding.media_file.save(content=myfile, name=output_name) encoding.media_file.save(content=myfile, name=output_name)
# encoding is saved, deleting chunks # encoding is saved, deleting chunks
@@ -1803,7 +1790,7 @@ def encoding_file_save(sender, instance, created, **kwargs):
chunks_paths = [f.media_file.path for f in chunks] chunks_paths = [f.media_file.path for f in chunks]
all_logs = "\n".join([st.logs for st in chunks]) all_logs = "\n".join([st.logs for st in chunks])
encoding.logs = "{0}\n{1}".format(chunks_paths, all_logs) encoding.logs = f"{chunks_paths}\n{all_logs}"
workers = list(set([st.worker for st in chunks])) workers = list(set([st.worker for st in chunks]))
encoding.worker = json.dumps({"workers": workers}) encoding.worker = json.dumps({"workers": workers})
start_date = min([st.add_date for st in chunks]) start_date = min([st.add_date for st in chunks])

View File

@@ -136,8 +136,8 @@ def chunkize_media(self, friendly_token, profiles, force=True):
cwd = os.path.dirname(os.path.realpath(media.media_file.path)) cwd = os.path.dirname(os.path.realpath(media.media_file.path))
file_name = media.media_file.path.split("/")[-1] file_name = media.media_file.path.split("/")[-1]
random_prefix = produce_friendly_token() random_prefix = produce_friendly_token()
file_format = "{0}_{1}".format(random_prefix, file_name) file_format = f"{random_prefix}_{file_name}"
chunks_file_name = "%02d_{0}".format(file_format) chunks_file_name = f"%02d_{file_format}"
chunks_file_name += ".mkv" chunks_file_name += ".mkv"
cmd = [ cmd = [
settings.FFMPEG_COMMAND, settings.FFMPEG_COMMAND,
@@ -162,7 +162,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
chunks.append(ch[0]) chunks.append(ch[0])
if not chunks: if not chunks:
# command completely failed to segment file.putting to normal encode # command completely failed to segment file.putting to normal encode
logger.info("Failed to break file {0} in chunks." " Putting to normal encode queue".format(friendly_token)) logger.info(f"Failed to break file {friendly_token} in chunks. Putting to normal encode queue")
for profile in profiles: for profile in profiles:
if media.video_height and media.video_height < profile.resolution: if media.video_height and media.video_height < profile.resolution:
if profile.resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE: if profile.resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
@@ -211,7 +211,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
priority=priority, priority=priority,
) )
logger.info("got {0} chunks and will encode to {1} profiles".format(len(chunks), to_profiles)) logger.info(f"got {len(chunks)} chunks and will encode to {to_profiles} profiles")
return True return True
@@ -355,8 +355,8 @@ def encode_media(
# return False # return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
tf = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir) tf = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
tfpass = create_temp_file(suffix=".{0}".format(profile.extension), dir=temp_dir) tfpass = create_temp_file(suffix=f".{profile.extension}", dir=temp_dir)
ffmpeg_commands = produce_ffmpeg_commands( ffmpeg_commands = produce_ffmpeg_commands(
original_media_path, original_media_path,
media.media_info, media.media_info,
@@ -398,7 +398,7 @@ def encode_media(
if n_times % 60 == 0: if n_times % 60 == 0:
encoding.progress = percent encoding.progress = percent
encoding.save(update_fields=["progress", "update_date"]) encoding.save(update_fields=["progress", "update_date"])
logger.info("Saved {0}".format(round(percent, 2))) logger.info(f"Saved {round(percent, 2)}")
n_times += 1 n_times += 1
except DatabaseError: except DatabaseError:
# primary reason for this is that the encoding has been deleted, because # primary reason for this is that the encoding has been deleted, because
@@ -451,7 +451,7 @@ def encode_media(
with open(tf, "rb") as f: with open(tf, "rb") as f:
myfile = File(f) myfile = File(f)
output_name = "{0}.{1}".format(get_file_name(original_media_path), profile.extension) output_name = f"{get_file_name(original_media_path)}.{profile.extension}"
encoding.media_file.save(content=myfile, name=output_name) encoding.media_file.save(content=myfile, name=output_name)
encoding.total_run_time = (encoding.update_date - encoding.add_date).seconds encoding.total_run_time = (encoding.update_date - encoding.add_date).seconds
@@ -472,7 +472,7 @@ def produce_sprite_from_video(friendly_token):
try: try:
media = Media.objects.get(friendly_token=friendly_token) media = Media.objects.get(friendly_token=friendly_token)
except BaseException: except BaseException:
logger.info("failed to get media with friendly_token %s" % friendly_token) logger.info(f"failed to get media with friendly_token {friendly_token}")
return False return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname: with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
@@ -516,7 +516,7 @@ def create_hls(friendly_token):
try: try:
media = Media.objects.get(friendly_token=friendly_token) media = Media.objects.get(friendly_token=friendly_token)
except BaseException: except BaseException:
logger.info("failed to get media with friendly_token %s" % friendly_token) logger.info(f"failed to get media with friendly_token {friendly_token}")
return False return False
p = media.uid.hex p = media.uid.hex
@@ -558,7 +558,7 @@ def check_running_states():
encodings = Encoding.objects.filter(status="running") encodings = Encoding.objects.filter(status="running")
logger.info("got {0} encodings that are in state running".format(encodings.count())) logger.info(f"got {encodings.count()} encodings that are in state running")
changed = 0 changed = 0
for encoding in encodings: for encoding in encodings:
now = datetime.now(encoding.update_date.tzinfo) now = datetime.now(encoding.update_date.tzinfo)
@@ -575,7 +575,7 @@ def check_running_states():
# TODO: allign with new code + chunksize... # TODO: allign with new code + chunksize...
changed += 1 changed += 1
if changed: if changed:
logger.info("changed from running to pending on {0} items".format(changed)) logger.info(f"changed from running to pending on {changed} items")
return True return True
@@ -585,7 +585,7 @@ def check_media_states():
# check encoding status of not success media # check encoding status of not success media
media = Media.objects.filter(Q(encoding_status="running") | Q(encoding_status="fail") | Q(encoding_status="pending")) media = Media.objects.filter(Q(encoding_status="running") | Q(encoding_status="fail") | Q(encoding_status="pending"))
logger.info("got {0} media that are not in state success".format(media.count())) logger.info(f"got {media.count()} media that are not in state success")
changed = 0 changed = 0
for m in media: for m in media:
@@ -593,7 +593,7 @@ def check_media_states():
m.save(update_fields=["encoding_status"]) m.save(update_fields=["encoding_status"])
changed += 1 changed += 1
if changed: if changed:
logger.info("changed encoding status to {0} media items".format(changed)) logger.info(f"changed encoding status to {changed} media items")
return True return True
@@ -628,7 +628,7 @@ def check_pending_states():
media.encode(profiles=[profile], force=False) media.encode(profiles=[profile], force=False)
changed += 1 changed += 1
if changed: if changed:
logger.info("set to the encode queue {0} encodings that were on pending state".format(changed)) logger.info(f"set to the encode queue {changed} encodings that were on pending state")
return True return True
@@ -652,7 +652,7 @@ def check_missing_profiles():
# if they appear on the meanwhile (eg on a big queue) # if they appear on the meanwhile (eg on a big queue)
changed += 1 changed += 1
if changed: if changed:
logger.info("set to the encode queue {0} profiles".format(changed)) logger.info(f"set to the encode queue {changed} profiles")
return True return True
@@ -828,7 +828,7 @@ def update_listings_thumbnails():
object.save(update_fields=["listings_thumbnail"]) object.save(update_fields=["listings_thumbnail"])
used_media.append(media.friendly_token) used_media.append(media.friendly_token)
saved += 1 saved += 1
logger.info("updated {} categories".format(saved)) logger.info(f"updated {saved} categories")
# Tags # Tags
used_media = [] used_media = []
@@ -841,7 +841,7 @@ def update_listings_thumbnails():
object.save(update_fields=["listings_thumbnail"]) object.save(update_fields=["listings_thumbnail"])
used_media.append(media.friendly_token) used_media.append(media.friendly_token)
saved += 1 saved += 1
logger.info("updated {} tags".format(saved)) logger.info(f"updated {saved} tags")
return True return True

View File

@@ -211,7 +211,7 @@ def contact(request):
name = request.POST.get("name") name = request.POST.get("name")
message = request.POST.get("message") message = request.POST.get("message")
title = "[{}] - Contact form message received".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - Contact form message received"
msg = """ msg = """
You have received a message through the contact form\n You have received a message through the contact form\n

View File

@@ -1 +1 @@
[{"model": "files.encodeprofile", "pk": 19, "fields": {"name": "h264-2160", "extension": "mp4", "resolution": 2160, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 22, "fields": {"name": "vp9-2160", "extension": "webm", "resolution": 2160, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 16, "fields": {"name": "h265-2160", "extension": "mp4", "resolution": 2160, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 4, "fields": {"name": "h264-1440", "extension": "mp4", "resolution": 1440, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 5, "fields": {"name": "vp9-1440", "extension": "webm", "resolution": 1440, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 6, "fields": {"name": "h265-1440", "extension": "mp4", "resolution": 1440, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 7, "fields": {"name": "h264-1080", "extension": "mp4", "resolution": 1080, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 8, "fields": {"name": "vp9-1080", "extension": "webm", "resolution": 1080, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 9, "fields": {"name": "h265-1080", "extension": "mp4", "resolution": 1080, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 10, "fields": {"name": "h264-720", "extension": "mp4", "resolution": 720, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 11, "fields": {"name": "vp9-720", "extension": "webm", "resolution": 720, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 12, "fields": {"name": "h265-720", "extension": "mp4", "resolution": 720, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 13, "fields": {"name": "h264-480", "extension": "mp4", "resolution": 480, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 14, "fields": {"name": "vp9-480", "extension": "webm", "resolution": 480, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 15, "fields": {"name": "h265-480", "extension": "mp4", "resolution": 480, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 3, "fields": {"name": "h264-360", "extension": "mp4", "resolution": 360, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 17, "fields": {"name": "vp9-360", "extension": "webm", "resolution": 360, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 18, "fields": {"name": "h265-360", "extension": "mp4", "resolution": 360, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 2, "fields": {"name": "h264-240", "extension": "mp4", "resolution": 240, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 20, "fields": {"name": "vp9-240", "extension": "webm", "resolution": 240, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 21, "fields": {"name": "h265-240", "extension": "mp4", "resolution": 240, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 1, "fields": {"name": "preview", "extension": "gif", "resolution": null, "codec": null, "description": "", "active": true}}] [{"model": "files.encodeprofile", "pk": 19, "fields": {"name": "h264-2160", "extension": "mp4", "resolution": 2160, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 22, "fields": {"name": "vp9-2160", "extension": "webm", "resolution": 2160, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 23, "fields": {"name": "h264-144", "extension": "mp4", "resolution": 144, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 16, "fields": {"name": "h265-2160", "extension": "mp4", "resolution": 2160, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 4, "fields": {"name": "h264-1440", "extension": "mp4", "resolution": 1440, "codec": "h264", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 5, "fields": {"name": "vp9-1440", "extension": "webm", "resolution": 1440, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 6, "fields": {"name": "h265-1440", "extension": "mp4", "resolution": 1440, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 7, "fields": {"name": "h264-1080", "extension": "mp4", "resolution": 1080, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 8, "fields": {"name": "vp9-1080", "extension": "webm", "resolution": 1080, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 9, "fields": {"name": "h265-1080", "extension": "mp4", "resolution": 1080, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 10, "fields": {"name": "h264-720", "extension": "mp4", "resolution": 720, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 11, "fields": {"name": "vp9-720", "extension": "webm", "resolution": 720, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 12, "fields": {"name": "h265-720", "extension": "mp4", "resolution": 720, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 13, "fields": {"name": "h264-480", "extension": "mp4", "resolution": 480, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 14, "fields": {"name": "vp9-480", "extension": "webm", "resolution": 480, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 15, "fields": {"name": "h265-480", "extension": "mp4", "resolution": 480, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 3, "fields": {"name": "h264-360", "extension": "mp4", "resolution": 360, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 17, "fields": {"name": "vp9-360", "extension": "webm", "resolution": 360, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 18, "fields": {"name": "h265-360", "extension": "mp4", "resolution": 360, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 2, "fields": {"name": "h264-240", "extension": "mp4", "resolution": 240, "codec": "h264", "description": "", "active": true}}, {"model": "files.encodeprofile", "pk": 20, "fields": {"name": "vp9-240", "extension": "webm", "resolution": 240, "codec": "vp9", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 21, "fields": {"name": "h265-240", "extension": "mp4", "resolution": 240, "codec": "h265", "description": "", "active": false}}, {"model": "files.encodeprofile", "pk": 1, "fields": {"name": "preview", "extension": "gif", "resolution": null, "codec": null, "description": "", "active": true}}]

View File

@@ -10,3 +10,6 @@ client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-37s.mp4 client/public/videos/sample-video-37s.mp4
videos/sample-video-37s.mp4 videos/sample-video-37s.mp4
client/public/videos/sample-video-30s.mp4 client/public/videos/sample-video-30s.mp4
client/public/videos/sample-video-1.mp4
client/public/videos/sample-video-10m.mp4
client/public/videos/sample-video-10s.mp4

View File

@@ -1 +0,0 @@
*

View File

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

View File

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

View File

@@ -128,4 +128,44 @@ npm run deploy
## API Integration ## API Integration
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits. The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
Sure! Here's your updated `README.md` section with a new **"Code Formatting"** section using Prettier. I placed it after the "Development" section to keep the flow logical:
---
## Code Formatting
To automatically format all source files using [Prettier](https://prettier.io):
```bash
# Format all code in the src directory
npx prettier --write src/
```
Or for specific file types:
```bash
cd frontend-tools/video-editor/
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
```
---
Let me know if you'd like to auto-format on commit using `lint-staged` + `husky`.

View File

@@ -16,7 +16,6 @@ const App = () => {
isPlaying, isPlaying,
setIsPlaying, setIsPlaying,
isMuted, isMuted,
isPreviewMode,
thumbnails, thumbnails,
trimStart, trimStart,
trimEnd, trimEnd,
@@ -34,7 +33,6 @@ const App = () => {
handleReset, handleReset,
handleUndo, handleUndo,
handleRedo, handleRedo,
handlePreview,
toggleMute, toggleMute,
handleSave, handleSave,
handleSaveACopy, handleSaveACopy,
@@ -43,7 +41,7 @@ const App = () => {
videoInitialized, videoInitialized,
setVideoInitialized, setVideoInitialized,
isPlayingSegments, isPlayingSegments,
handlePlaySegments, handlePlaySegments
} = useVideoTrimmer(); } = useVideoTrimmer();
// Function to play from the beginning // Function to play from the beginning
@@ -71,31 +69,31 @@ const App = () => {
const handlePlay = () => { const handlePlay = () => {
if (!videoRef.current) return; if (!videoRef.current) return;
const video = videoRef.current; const video = videoRef.current;
// If already playing, just pause the video // If already playing, just pause the video
if (isPlaying) { if (isPlaying) {
video.pause(); video.pause();
setIsPlaying(false); setIsPlaying(false);
return; return;
} }
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
// Find the next stopping point based on current position // Find the next stopping point based on current position
let stopTime = duration; let stopTime = duration;
let currentSegment = null; let currentSegment = null;
let nextSegment = null; let nextSegment = null;
// Sort segments by start time to ensure correct order // Sort segments by start time to ensure correct order
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// First, check if we're inside a segment or exactly at its start/end // First, check if we're inside a segment or exactly at its start/end
currentSegment = sortedSegments.find(seg => { currentSegment = sortedSegments.find((seg) => {
const segStartTime = Number(seg.startTime.toFixed(6)); const segStartTime = Number(seg.startTime.toFixed(6));
const segEndTime = Number(seg.endTime.toFixed(6)); const segEndTime = Number(seg.endTime.toFixed(6));
// Check if we're inside the segment // Check if we're inside the segment
if (currentPosition > segStartTime && currentPosition < segEndTime) { if (currentPosition > segStartTime && currentPosition < segEndTime) {
return true; return true;
@@ -111,15 +109,15 @@ const App = () => {
} }
return false; return false;
}); });
// If we're not in a segment, find the next segment // If we're not in a segment, find the next segment
if (!currentSegment) { if (!currentSegment) {
nextSegment = sortedSegments.find(seg => { nextSegment = sortedSegments.find((seg) => {
const segStartTime = Number(seg.startTime.toFixed(6)); const segStartTime = Number(seg.startTime.toFixed(6));
return segStartTime > currentPosition; return segStartTime > currentPosition;
}); });
} }
// Determine where to stop based on position // Determine where to stop based on position
if (currentSegment) { if (currentSegment) {
// If we're in a segment, stop at its end // If we're in a segment, stop at its end
@@ -128,113 +126,123 @@ const App = () => {
// If we're in a cutaway and there's a next segment, stop at its start // If we're in a cutaway and there's a next segment, stop at its start
stopTime = Number(nextSegment.startTime.toFixed(6)); stopTime = Number(nextSegment.startTime.toFixed(6));
} }
// Create a boundary checker function with high precision // Create a boundary checker function with high precision
const checkBoundary = () => { const checkBoundary = () => {
if (!video) return; if (!video) return;
const currentPosition = Number(video.currentTime.toFixed(6)); const currentPosition = Number(video.currentTime.toFixed(6));
const timeLeft = Number((stopTime - currentPosition).toFixed(6)); const timeLeft = Number((stopTime - currentPosition).toFixed(6));
// If we've reached or passed the boundary // If we've reached or passed the boundary
if (timeLeft <= 0 || currentPosition >= stopTime) { if (timeLeft <= 0 || currentPosition >= stopTime) {
// First pause playback // First pause playback
video.pause(); video.pause();
// Force exact position with multiple verification attempts // Force exact position with multiple verification attempts
const setExactPosition = () => { const setExactPosition = () => {
if (!video) return; if (!video) return;
// Set to exact boundary time // Set to exact boundary time
video.currentTime = stopTime; video.currentTime = stopTime;
handleMobileSafeSeek(stopTime); handleMobileSafeSeek(stopTime);
const actualPosition = Number(video.currentTime.toFixed(6)); const actualPosition = Number(video.currentTime.toFixed(6));
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6)); const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
logger.debug("Position verification:", { logger.debug("Position verification:", {
target: formatDetailedTime(stopTime), target: formatDetailedTime(stopTime),
actual: formatDetailedTime(actualPosition), actual: formatDetailedTime(actualPosition),
difference: difference difference: difference
}); });
// If we're not exactly at the target position, try one more time // If we're not exactly at the target position, try one more time
if (difference > 0) { if (difference > 0) {
video.currentTime = stopTime; video.currentTime = stopTime;
handleMobileSafeSeek(stopTime); handleMobileSafeSeek(stopTime);
} }
}; };
// Multiple attempts to ensure precision, with increasing delays // Multiple attempts to ensure precision, with increasing delays
setExactPosition(); setExactPosition();
setTimeout(setExactPosition, 5); // Quick first retry setTimeout(setExactPosition, 5); // Quick first retry
setTimeout(setExactPosition, 10); // Second retry setTimeout(setExactPosition, 10); // Second retry
setTimeout(setExactPosition, 20); // Third retry if needed setTimeout(setExactPosition, 20); // Third retry if needed
setTimeout(setExactPosition, 50); // Final verification setTimeout(setExactPosition, 50); // Final verification
// Remove our boundary checker // Remove our boundary checker
video.removeEventListener('timeupdate', checkBoundary); video.removeEventListener("timeupdate", checkBoundary);
setIsPlaying(false); setIsPlaying(false);
// Log the final position for debugging // Log the final position for debugging
logger.debug("Stopped at position:", { logger.debug("Stopped at position:", {
target: formatDetailedTime(stopTime), target: formatDetailedTime(stopTime),
actual: formatDetailedTime(video.currentTime), actual: formatDetailedTime(video.currentTime),
type: currentSegment ? "segment end" : (nextSegment ? "next segment start" : "end of video"), type: currentSegment
segment: currentSegment ? { ? "segment end"
id: currentSegment.id, : nextSegment
start: formatDetailedTime(currentSegment.startTime), ? "next segment start"
end: formatDetailedTime(currentSegment.endTime) : "end of video",
} : null, segment: currentSegment
nextSegment: nextSegment ? { ? {
id: nextSegment.id, id: currentSegment.id,
start: formatDetailedTime(nextSegment.startTime), start: formatDetailedTime(currentSegment.startTime),
end: formatDetailedTime(nextSegment.endTime) end: formatDetailedTime(currentSegment.endTime)
} : null }
: null,
nextSegment: nextSegment
? {
id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
}
: null
}); });
return; return;
} }
}; };
// Start our boundary checker // Start our boundary checker
video.addEventListener('timeupdate', checkBoundary); video.addEventListener("timeupdate", checkBoundary);
// Start playing // Start playing
video.play() video
.play()
.then(() => { .then(() => {
setIsPlaying(true); setIsPlaying(true);
setVideoInitialized(true); setVideoInitialized(true);
logger.debug("Playback started:", { logger.debug("Playback started:", {
from: formatDetailedTime(currentPosition), from: formatDetailedTime(currentPosition),
to: formatDetailedTime(stopTime), to: formatDetailedTime(stopTime),
currentSegment: currentSegment ? { currentSegment: currentSegment
id: currentSegment.id, ? {
start: formatDetailedTime(currentSegment.startTime), id: currentSegment.id,
end: formatDetailedTime(currentSegment.endTime) start: formatDetailedTime(currentSegment.startTime),
} : 'None', end: formatDetailedTime(currentSegment.endTime)
nextSegment: nextSegment ? { }
id: nextSegment.id, : "None",
start: formatDetailedTime(nextSegment.startTime), nextSegment: nextSegment
end: formatDetailedTime(nextSegment.endTime) ? {
} : 'None' id: nextSegment.id,
start: formatDetailedTime(nextSegment.startTime),
end: formatDetailedTime(nextSegment.endTime)
}
: "None"
}); });
}) })
.catch(err => { .catch((err) => {
console.error("Error playing video:", err); console.error("Error playing video:", err);
}); });
}; };
return ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<MobilePlayPrompt <MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
videoRef={videoRef}
onPlay={handlePlay}
/>
<div className="container mx-auto px-4 py-6 max-w-6xl"> <div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Video Player */} {/* Video Player */}
<VideoPlayer <VideoPlayer
videoRef={videoRef} videoRef={videoRef}
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
@@ -246,15 +254,13 @@ const App = () => {
/> />
{/* Editing Tools */} {/* Editing Tools */}
<EditingTools <EditingTools
onSplit={handleSplit} onSplit={handleSplit}
onReset={handleReset} onReset={handleReset}
onUndo={handleUndo} onUndo={handleUndo}
onRedo={handleRedo} onRedo={handleRedo}
onPreview={handlePreview}
onPlaySegments={handlePlaySegments} onPlaySegments={handlePlaySegments}
onPlay={handlePlay} onPlay={handlePlay}
isPreviewMode={isPreviewMode}
isPlaying={isPlaying} isPlaying={isPlaying}
isPlayingSegments={isPlayingSegments} isPlayingSegments={isPlayingSegments}
canUndo={historyPosition > 0} canUndo={historyPosition > 0}
@@ -262,7 +268,7 @@ const App = () => {
/> />
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
thumbnails={thumbnails} thumbnails={thumbnails}
@@ -279,7 +285,6 @@ const App = () => {
onSave={handleSave} onSave={handleSave}
onSaveACopy={handleSaveACopy} onSaveACopy={handleSaveACopy}
onSaveSegments={handleSaveSegments} onSaveSegments={handleSaveSegments}
isPreviewMode={isPreviewMode}
hasUnsavedChanges={hasUnsavedChanges} hasUnsavedChanges={hasUnsavedChanges}
isIOSUninitialized={isMobile && !videoInitialized} isIOSUninitialized={isMobile && !videoInitialized}
isPlaying={isPlaying} isPlaying={isPlaying}

View File

@@ -1,5 +1,5 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils"; import { formatTime, formatLongTime } from "@/lib/timeUtils";
import '../styles/ClipSegments.css'; import "../styles/ClipSegments.css";
export interface Segment { export interface Segment {
id: number; id: number;
@@ -16,41 +16,36 @@ interface ClipSegmentsProps {
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 <div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
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"> <div className="segment-title">Segment {index + 1}</div>
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>
@@ -60,20 +55,24 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
</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 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" /> <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> </svg>
</button> </button>
</div> </div>
</div> </div>
))} ))}
{sortedSegments.length === 0 && ( {sortedSegments.length === 0 && (
<div className="empty-message"> <div className="empty-message">
No segments created yet. Use the split button to create segments. No segments created yet. Use the split button to create segments.

View File

@@ -1,17 +1,15 @@
import '../styles/EditingTools.css'; import "../styles/EditingTools.css";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
interface EditingToolsProps { interface EditingToolsProps {
onSplit: () => void; onSplit: () => void;
onReset: () => void; onReset: () => void;
onUndo: () => void; onUndo: () => void;
onRedo: () => void; onRedo: () => void;
onPreview: () => void;
onPlaySegments: () => void; onPlaySegments: () => void;
onPlay: () => void; onPlay: () => void;
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
isPreviewMode?: boolean;
isPlaying?: boolean; isPlaying?: boolean;
isPlayingSegments?: boolean; isPlayingSegments?: boolean;
} }
@@ -21,14 +19,12 @@ const EditingTools = ({
onReset, onReset,
onUndo, onUndo,
onRedo, onRedo,
onPreview,
onPlaySegments, onPlaySegments,
onPlay, onPlay,
canUndo, canUndo,
canRedo, canRedo,
isPreviewMode = false,
isPlaying = false, isPlaying = false,
isPlayingSegments = false, isPlayingSegments = false
}: EditingToolsProps) => { }: EditingToolsProps) => {
const [isSmallScreen, setIsSmallScreen] = useState(false); const [isSmallScreen, setIsSmallScreen] = useState(false);
@@ -38,17 +34,17 @@ const EditingTools = ({
}; };
checkScreenSize(); checkScreenSize();
window.addEventListener('resize', checkScreenSize); window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize); return () => window.removeEventListener("resize", checkScreenSize);
}, []); }, []);
// Handle play button click with iOS fix // Handle play button click with iOS fix
const handlePlay = () => { const handlePlay = () => {
// Ensure lastSeekedPosition is used when play is clicked // Ensure lastSeekedPosition is used when play is clicked
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition); console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
} }
// Call the original handler // Call the original handler
onPlay(); onPlay();
}; };
@@ -59,15 +55,25 @@ const EditingTools = ({
{/* Left side - Play buttons group */} {/* Left side - Play buttons group */}
<div className="button-group play-buttons-group"> <div className="button-group play-buttons-group">
{/* Play Segments button */} {/* Play Segments button */}
<button <button
className={`button segments-button`} className={`button segments-button`}
onClick={onPlaySegments} onClick={onPlaySegments}
data-tooltip={isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"} data-tooltip={
style={{ fontSize: '0.875rem' }} isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
}
style={{ fontSize: "0.875rem" }}
> >
{isPlayingSegments ? ( {isPlayingSegments ? (
<> <>
<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" />
<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" />
@@ -77,7 +83,15 @@ const EditingTools = ({
</> </>
) : ( ) : (
<> <>
<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" />
<polygon points="10 8 16 12 10 16 10 8" /> <polygon points="10 8 16 12 10 16 10 8" />
</svg> </svg>
@@ -116,18 +130,26 @@ const EditingTools = ({
)} )}
</button> */} </button> */}
{/* Standard Play button (only shown when not in preview mode or segments playback) */} {/* Standard Play button (only shown when not in segments playback on small screens) */}
{!isPreviewMode && (!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 ? ( {isPlaying && !isPlayingSegments ? (
<> <>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <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" />
<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" />
@@ -137,7 +159,15 @@ const EditingTools = ({
</> </>
) : ( ) : (
<> <>
<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" />
<polygon points="10 8 16 12 10 16 10 8" /> <polygon points="10 8 16 12 10 16 10 8" />
</svg> </svg>
@@ -147,7 +177,7 @@ const EditingTools = ({
)} )}
</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">
@@ -159,7 +189,7 @@ const EditingTools = ({
Preview Mode Preview Mode
</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">
@@ -172,43 +202,64 @@ const EditingTools = ({
</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="Undo last action" data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
disabled={!canUndo} disabled={!canUndo || isPlayingSegments}
onClick={onUndo} 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"> <svg
<path d="M9 14 4 9l5-5"/> xmlns="http://www.w3.org/2000/svg"
<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"/> 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> </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="Redo last undone action" data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
disabled={!canRedo} disabled={!canRedo || isPlayingSegments}
onClick={onRedo} 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"> <svg
<path d="m15 14 5-5-5-5"/> xmlns="http://www.w3.org/2000/svg"
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/> 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> </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="Reset to full video" data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
disabled={isPlayingSegments}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <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" /> <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> </svg>
<span className="reset-text">Reset</span> <span className="reset-text">Reset</span>
</button> </button>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import '../styles/IOSPlayPrompt.css'; import "../styles/IOSPlayPrompt.css";
interface MobilePlayPromptProps { interface MobilePlayPromptProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
@@ -13,7 +13,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
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(navigator.userAgent); return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
navigator.userAgent
);
}; };
// Always show for mobile devices on each visit // Always show for mobile devices on each visit
@@ -31,9 +33,9 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
setIsVisible(false); setIsVisible(false);
}; };
video.addEventListener('play', handlePlay); video.addEventListener("play", handlePlay);
return () => { return () => {
video.removeEventListener('play', handlePlay); video.removeEventListener("play", handlePlay);
}; };
}, [videoRef]); }, [videoRef]);
@@ -62,11 +64,8 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
<li>Then you'll be able to use all timeline controls</li> <li>Then you'll be able to use all timeline controls</li>
</ol> </ol>
</div> */} </div> */}
<button <button className="mobile-play-button" onClick={handlePlayClick}>
className="mobile-play-button"
onClick={handlePlayClick}
>
Click to start editing... Click to start editing...
</button> </button>
</div> </div>
@@ -74,4 +73,4 @@ const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay })
); );
}; };
export default MobilePlayPrompt; export default MobilePlayPrompt;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { formatTime } from "@/lib/timeUtils"; import { formatTime } from "@/lib/timeUtils";
import '../styles/IOSVideoPlayer.css'; import "../styles/IOSVideoPlayer.css";
interface IOSVideoPlayerProps { interface IOSVideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
@@ -8,14 +8,10 @@ interface IOSVideoPlayerProps {
duration: number; duration: number;
} }
const IOSVideoPlayer = ({ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
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);
// 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);
@@ -27,17 +23,17 @@ const IOSVideoPlayer = ({
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
}; };
}, []); }, []);
// Get the video source URL from the main player // Get the video source URL from the main player
useEffect(() => { useEffect(() => {
if (videoRef.current && videoRef.current.querySelector('source')) { if (videoRef.current && videoRef.current.querySelector("source")) {
const source = videoRef.current.querySelector('source') as HTMLSourceElement; const source = videoRef.current.querySelector("source") as HTMLSourceElement;
if (source && source.src) { if (source && source.src) {
setVideoUrl(source.src); setVideoUrl(source.src);
} }
} else { } else {
// Fallback to sample video if needed // Fallback to sample video if needed
setVideoUrl("/videos/sample-video-37s.mp4"); setVideoUrl("/videos/sample-video-10m.mp4");
} }
}, [videoRef]); }, [videoRef]);
@@ -61,13 +57,13 @@ const IOSVideoPlayer = ({
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => { const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection // Prevent default to avoid text selection
e.preventDefault(); e.preventDefault();
if (!iosVideoRef) return; if (!iosVideoRef) return;
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current); if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
// First immediate adjustment // First immediate adjustment
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05); iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
// Setup continuous adjustment // Setup continuous adjustment
incrementIntervalRef.current = setInterval(() => { incrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) { if (iosVideoRef) {
@@ -88,13 +84,13 @@ const IOSVideoPlayer = ({
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => { const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
// Prevent default to avoid text selection // Prevent default to avoid text selection
e.preventDefault(); e.preventDefault();
if (!iosVideoRef) return; if (!iosVideoRef) return;
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current); if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
// First immediate adjustment // First immediate adjustment
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05); iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
// Setup continuous adjustment // Setup continuous adjustment
decrementIntervalRef.current = setInterval(() => { decrementIntervalRef.current = setInterval(() => {
if (iosVideoRef) { if (iosVideoRef) {
@@ -115,12 +111,14 @@ const IOSVideoPlayer = ({
<div className="ios-video-player-container"> <div className="ios-video-player-container">
{/* Current Time / Duration Display */} {/* Current Time / Duration Display */}
<div className="ios-time-display mb-2"> <div className="ios-time-display mb-2">
<span className="text-sm">{formatTime(currentTime)} / {formatTime(duration)}</span> <span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div> </div>
{/* iOS-optimized Video Element with Native Controls */} {/* iOS-optimized Video Element with Native Controls */}
<video <video
ref={ref => setIosVideoRef(ref)} ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md" className="w-full rounded-md"
src={videoUrl} src={videoUrl}
controls controls
@@ -133,26 +131,26 @@ const IOSVideoPlayer = ({
<source src={videoUrl} type="video/mp4" /> <source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>
</video> </video>
{/* iOS Video Skip Controls */} {/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">
<button <button
onClick={jumpBackward15} onClick={jumpBackward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md" className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
> >
-15s -15s
</button> </button>
<button <button
onClick={jumpForward15} onClick={jumpForward15}
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md" className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
> >
+15s +15s
</button> </button>
</div> </div>
{/* iOS Fine Control Buttons */} {/* iOS Fine Control Buttons */}
<div className="ios-fine-controls mt-2 flex justify-center gap-4"> <div className="ios-fine-controls mt-2 flex justify-center gap-4">
<button <button
onMouseDown={startDecrement} onMouseDown={startDecrement}
onTouchStart={startDecrement} onTouchStart={startDecrement}
onMouseUp={stopDecrement} onMouseUp={stopDecrement}
@@ -163,7 +161,7 @@ const IOSVideoPlayer = ({
> >
-50ms -50ms
</button> </button>
<button <button
onMouseDown={startIncrement} onMouseDown={startIncrement}
onTouchStart={startIncrement} onTouchStart={startIncrement}
onMouseUp={stopIncrement} onMouseUp={stopIncrement}
@@ -175,7 +173,7 @@ const IOSVideoPlayer = ({
+50ms +50ms
</button> </button>
</div> </div>
<div className="ios-note mt-2 text-xs text-gray-500"> <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> <p>This player uses native iOS controls for better compatibility with iOS devices.</p>
</div> </div>
@@ -183,4 +181,4 @@ const IOSVideoPlayer = ({
); );
}; };
export default IOSVideoPlayer; export default IOSVideoPlayer;

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useState } from "react"; import React, { useRef, useEffect, useState } from "react";
import { formatTime, formatDetailedTime } from "@/lib/timeUtils"; import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
import logger from '../lib/logger'; import logger from "../lib/logger";
import '../styles/VideoPlayer.css'; import "../styles/VideoPlayer.css";
interface VideoPlayerProps { interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>; videoRef: React.RefObject<HTMLVideoElement>;
@@ -32,37 +32,37 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const isDraggingProgressRef = useRef(false); const isDraggingProgressRef = useRef(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 }); const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
const [tooltipTime, setTooltipTime] = useState(0); const [tooltipTime, setTooltipTime] = useState(0);
const sampleVideoUrl = typeof window !== 'undefined' && const sampleVideoUrl =
(window as any).MEDIA_DATA?.videoUrl || (typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
"/videos/sample-video-37s.mp4"; "/videos/sample-video-10m.mp4";
// Detect iOS device // Detect iOS device
useEffect(() => { useEffect(() => {
const checkIOS = () => { const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
}; };
setIsIOS(checkIOS()); setIsIOS(checkIOS());
// Check if video was previously initialized // Check if video was previously initialized
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
const wasInitialized = localStorage.getItem('video_initialized') === 'true'; const wasInitialized = localStorage.getItem("video_initialized") === "true";
setHasInitialized(wasInitialized); setHasInitialized(wasInitialized);
} }
}, []); }, []);
// Update initialized state when video plays // Update initialized state when video plays
useEffect(() => { useEffect(() => {
if (isPlaying && !hasInitialized) { if (isPlaying && !hasInitialized) {
setHasInitialized(true); setHasInitialized(true);
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
localStorage.setItem('video_initialized', 'true'); localStorage.setItem("video_initialized", "true");
} }
} }
}, [isPlaying, hasInitialized]); }, [isPlaying, hasInitialized]);
// Add iOS-specific attributes to prevent fullscreen playback // Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -70,15 +70,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// These attributes need to be set directly on the DOM element // These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback // for iOS Safari to respect inline playback
video.setAttribute('playsinline', 'true'); video.setAttribute("playsinline", "true");
video.setAttribute('webkit-playsinline', 'true'); video.setAttribute("webkit-playsinline", "true");
video.setAttribute('x-webkit-airplay', 'allow'); video.setAttribute("x-webkit-airplay", "allow");
// Store the last known good position for iOS // Store the last known good position for iOS
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
if (!isDraggingProgressRef.current) { if (!isDraggingProgressRef.current) {
setLastPosition(video.currentTime); setLastPosition(video.currentTime);
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.lastSeekedPosition = video.currentTime; window.lastSeekedPosition = video.currentTime;
} }
} }
@@ -86,33 +86,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// Handle iOS-specific play/pause state // Handle iOS-specific play/pause state
const handlePlay = () => { const handlePlay = () => {
logger.debug('Video play event fired'); logger.debug("Video play event fired");
if (isIOS) { if (isIOS) {
setHasInitialized(true); setHasInitialized(true);
localStorage.setItem('video_initialized', 'true'); localStorage.setItem("video_initialized", "true");
} }
}; };
const handlePause = () => { const handlePause = () => {
logger.debug('Video pause event fired'); logger.debug("Video pause event fired");
}; };
video.addEventListener('timeupdate', handleTimeUpdate); video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener('play', handlePlay); video.addEventListener("play", handlePlay);
video.addEventListener('pause', handlePause); video.addEventListener("pause", handlePause);
return () => { return () => {
video.removeEventListener('timeupdate', handleTimeUpdate); video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener('play', handlePlay); video.removeEventListener("play", handlePlay);
video.removeEventListener('pause', handlePause); video.removeEventListener("pause", handlePause);
}; };
}, [videoRef, isIOS, isDraggingProgressRef]); }, [videoRef, isIOS, isDraggingProgressRef]);
// Save current time to lastPosition when it changes (from external seeking) // Save current time to lastPosition when it changes (from external seeking)
useEffect(() => { useEffect(() => {
setLastPosition(currentTime); setLastPosition(currentTime);
}, [currentTime]); }, [currentTime]);
// Jump 10 seconds forward // Jump 10 seconds forward
const handleForward = () => { const handleForward = () => {
const newTime = Math.min(currentTime + 10, duration); const newTime = Math.min(currentTime + 10, duration);
@@ -126,58 +126,58 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
onSeek(newTime); onSeek(newTime);
setLastPosition(newTime); setLastPosition(newTime);
}; };
// Calculate progress percentage // Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0; const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle start of progress bar dragging // Handle start of progress bar dragging
const handleProgressDragStart = (e: React.MouseEvent) => { const handleProgressDragStart = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
setIsDraggingProgress(true); setIsDraggingProgress(true);
isDraggingProgressRef.current = true; isDraggingProgressRef.current = true;
// Get initial position // Get initial position
handleProgressDrag(e); handleProgressDrag(e);
// Set up document-level event listeners for mouse movement and release // Set up document-level event listeners for mouse movement and release
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
if (isDraggingProgressRef.current) { if (isDraggingProgressRef.current) {
handleProgressDrag(moveEvent); handleProgressDrag(moveEvent);
} }
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
setIsDraggingProgress(false); setIsDraggingProgress(false);
isDraggingProgressRef.current = false; isDraggingProgressRef.current = false;
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}; };
// Handle progress dragging for both mouse and touch events // Handle progress dragging for both mouse and touch events
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => { const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
if (!progressRef.current) return; if (!progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect(); const rect = progressRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = duration * clickPosition; const seekTime = duration * clickPosition;
// Update tooltip position and time // Update tooltip position and time
setTooltipPosition({ x: e.clientX }); setTooltipPosition({ x: e.clientX });
setTooltipTime(seekTime); setTooltipTime(seekTime);
// Store position locally for iOS Safari - critical for timeline seeking // Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime); setLastPosition(seekTime);
// Also store globally for integration with other components // Also store globally for integration with other components
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime; (window as any).lastSeekedPosition = seekTime;
} }
onSeek(seekTime); onSeek(seekTime);
}; };
@@ -185,59 +185,59 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleProgressTouchStart = (e: React.TouchEvent) => { const handleProgressTouchStart = (e: React.TouchEvent) => {
if (!progressRef.current || !e.touches[0]) return; if (!progressRef.current || !e.touches[0]) return;
e.preventDefault(); e.preventDefault();
setIsDraggingProgress(true); setIsDraggingProgress(true);
isDraggingProgressRef.current = true; isDraggingProgressRef.current = true;
// Get initial position using touch // Get initial position using touch
handleProgressTouchMove(e); handleProgressTouchMove(e);
// Set up document-level event listeners for touch movement and release // Set up document-level event listeners for touch movement and release
const handleTouchMove = (moveEvent: TouchEvent) => { const handleTouchMove = (moveEvent: TouchEvent) => {
if (isDraggingProgressRef.current) { if (isDraggingProgressRef.current) {
handleProgressTouchMove(moveEvent); handleProgressTouchMove(moveEvent);
} }
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
setIsDraggingProgress(false); setIsDraggingProgress(false);
isDraggingProgressRef.current = false; isDraggingProgressRef.current = false;
document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener("touchend", handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd); document.removeEventListener("touchcancel", handleTouchEnd);
}; };
document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd); document.addEventListener("touchend", handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd); document.addEventListener("touchcancel", handleTouchEnd);
}; };
// Handle touch dragging on progress bar // Handle touch dragging on progress bar
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => { const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
if (!progressRef.current) return; if (!progressRef.current) return;
// Get the touch coordinates // Get the touch coordinates
const touch = 'touches' in e ? e.touches[0] : null; const touch = "touches" in e ? e.touches[0] : null;
if (!touch) return; if (!touch) return;
e.preventDefault(); // Prevent scrolling while dragging e.preventDefault(); // Prevent scrolling while dragging
const rect = progressRef.current.getBoundingClientRect(); const rect = progressRef.current.getBoundingClientRect();
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
const seekTime = duration * touchPosition; const seekTime = duration * touchPosition;
// Update tooltip position and time // Update tooltip position and time
setTooltipPosition({ x: touch.clientX }); setTooltipPosition({ x: touch.clientX });
setTooltipTime(seekTime); setTooltipTime(seekTime);
// Store position for iOS Safari // Store position for iOS Safari
setLastPosition(seekTime); setLastPosition(seekTime);
// Also store globally for integration with other components // Also store globally for integration with other components
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime; (window as any).lastSeekedPosition = seekTime;
} }
onSeek(seekTime); onSeek(seekTime);
}; };
@@ -245,20 +245,20 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we're already dragging, don't handle the click // If we're already dragging, don't handle the click
if (isDraggingProgress) return; if (isDraggingProgress) return;
if (progressRef.current) { if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect(); const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width; const clickPosition = (e.clientX - rect.left) / rect.width;
const seekTime = duration * clickPosition; const seekTime = duration * clickPosition;
// Store position locally for iOS Safari - critical for timeline seeking // Store position locally for iOS Safari - critical for timeline seeking
setLastPosition(seekTime); setLastPosition(seekTime);
// Also store globally for integration with other components // Also store globally for integration with other components
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
(window as any).lastSeekedPosition = seekTime; (window as any).lastSeekedPosition = seekTime;
} }
onSeek(seekTime); onSeek(seekTime);
} }
}; };
@@ -278,38 +278,43 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleVideoClick = () => { const handleVideoClick = () => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
// If the video is paused, we want to play it // If the video is paused, we want to play it
if (video.paused) { if (video.paused) {
// For iOS Safari: Before playing, explicitly seek to the remembered position // For iOS Safari: Before playing, explicitly seek to the remembered position
if (isIOS && lastPosition !== null && lastPosition > 0) { if (isIOS && lastPosition !== null && lastPosition > 0) {
logger.debug("iOS: Explicitly setting position before play:", lastPosition); logger.debug("iOS: Explicitly setting position before play:", lastPosition);
// First, seek to the position // First, seek to the position
video.currentTime = lastPosition; video.currentTime = lastPosition;
// Use a small timeout to ensure seeking is complete before play // Use a small timeout to ensure seeking is complete before play
setTimeout(() => { setTimeout(() => {
if (videoRef.current) { if (videoRef.current) {
// Try to play with proper promise handling // Try to play with proper promise handling
videoRef.current.play() videoRef.current
.play()
.then(() => { .then(() => {
logger.debug("iOS: Play started successfully at position:", videoRef.current?.currentTime); logger.debug(
"iOS: Play started successfully at position:",
videoRef.current?.currentTime
);
onPlayPause(); // Update parent state after successful play onPlayPause(); // Update parent state after successful play
}) })
.catch(err => { .catch((err) => {
console.error("iOS: Error playing video:", err); console.error("iOS: Error playing video:", err);
}); });
} }
}, 50); }, 50);
} else { } else {
// Normal play (non-iOS or no remembered position) // Normal play (non-iOS or no remembered position)
video.play() video
.play()
.then(() => { .then(() => {
logger.debug("Normal: Play started successfully"); logger.debug("Normal: Play started successfully");
onPlayPause(); // Update parent state after successful play onPlayPause(); // Update parent state after successful play
}) })
.catch(err => { .catch((err) => {
console.error("Error playing video:", err); console.error("Error playing video:", err);
}); });
} }
@@ -336,19 +341,17 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<source src={sampleVideoUrl} type="video/mp4" /> <source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p> <p>Your browser doesn't support HTML5 video.</p>
</video> </video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */} {/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
{isIOS && !hasInitialized && !isPlaying && ( {isIOS && !hasInitialized && !isPlaying && (
<div className="ios-first-play-indicator"> <div className="ios-first-play-indicator">
<div className="ios-play-message"> <div className="ios-play-message">Tap Play to initialize video controls</div>
Tap Play to initialize video controls
</div>
</div> </div>
)} )}
{/* Play/Pause Indicator (shows based on current state) */} {/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div> <div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
{/* Video Controls Overlay */} {/* Video Controls Overlay */}
<div className="video-controls"> <div className="video-controls">
{/* Time and Duration */} {/* Time and Duration */}
@@ -356,47 +359,52 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<span className="video-current-time">{formatTime(currentTime)}</span> <span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span> <span className="video-duration">/ {formatTime(duration)}</span>
</div> </div>
{/* Progress Bar with enhanced dragging */} {/* Progress Bar with enhanced dragging */}
<div <div
ref={progressRef} ref={progressRef}
className={`video-progress ${isDraggingProgress ? 'dragging' : ''}`} className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
onClick={handleProgressClick} onClick={handleProgressClick}
onMouseDown={handleProgressDragStart} onMouseDown={handleProgressDragStart}
onTouchStart={handleProgressTouchStart} onTouchStart={handleProgressTouchStart}
> >
<div <div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
className="video-progress-fill" <div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
style={{ width: `${progressPercentage}%` }}
></div>
<div
className="video-scrubber"
style={{ left: `${progressPercentage}%` }}
></div>
{/* Floating time tooltip when dragging */} {/* Floating time tooltip when dragging */}
{isDraggingProgress && ( {isDraggingProgress && (
<div className="video-time-tooltip" style={{ <div
left: `${tooltipPosition.x}px`, className="video-time-tooltip"
transform: 'translateX(-50%)' style={{
}}> left: `${tooltipPosition.x}px`,
transform: "translateX(-50%)"
}}
>
{formatDetailedTime(tooltipTime)} {formatDetailedTime(tooltipTime)}
</div> </div>
)} )}
</div> </div>
{/* Controls - Mute and Fullscreen buttons */} {/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons"> <div className="video-controls-buttons">
{/* Mute/Unmute Button */} {/* Mute/Unmute Button */}
{onToggleMute && ( {onToggleMute && (
<button <button
className="mute-button" className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"} aria-label={isMuted ? "Unmute" : "Mute"}
onClick={onToggleMute} onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"} data-tooltip={isMuted ? "Unmute" : "Mute"}
> >
{isMuted ? ( {isMuted ? (
<svg 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"
>
<line x1="1" y1="1" x2="23" y2="23"></line> <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="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> <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
@@ -404,23 +412,35 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
<line x1="8" y1="23" x2="16" y2="23"></line> <line x1="8" y1="23" x2="16" y2="23"></line>
</svg> </svg>
) : ( ) : (
<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"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> <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> <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> </svg>
)} )}
</button> </button>
)} )}
{/* Fullscreen Button */} {/* Fullscreen Button */}
<button <button
className="fullscreen-button" className="fullscreen-button"
aria-label="Fullscreen" aria-label="Fullscreen"
onClick={handleFullscreen} onClick={handleFullscreen}
data-tooltip="Toggle fullscreen" data-tooltip="Toggle fullscreen"
> >
<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 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" /> <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> </svg>
</button> </button>
</div> </div>

View File

@@ -125,13 +125,13 @@
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
background-color: #EEE; /* Very light gray background */ background-color: #eee; /* Very light gray background */
position: relative; position: relative;
} }
.timeline-container { .timeline-container {
position: relative; position: relative;
background-color: #EEE; /* Very light gray background */ background-color: #eee; /* Very light gray background */
height: 6rem; height: 6rem;
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
@@ -208,17 +208,27 @@
overflow: hidden; overflow: hidden;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
transition: box-shadow 0.2s, transform 0.1s; transition:
box-shadow 0.2s,
transform 0.1s;
/* Original z-index for stacking order based on segment ID */ /* Original z-index for stacking order based on segment ID */
z-index: 15; z-index: 15;
} }
/* No background colors for segments, just borders with 2-color scheme */ /* 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 { .clip-segment:nth-child(odd),
.segment-color-1,
.segment-color-3,
.segment-color-5,
.segment-color-7 {
background-color: transparent; background-color: transparent;
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */ 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 { .clip-segment:nth-child(even),
.segment-color-2,
.segment-color-4,
.segment-color-6,
.segment-color-8 {
background-color: transparent; background-color: transparent;
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */ border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
} }
@@ -315,7 +325,7 @@
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
height: 6px; height: 6px;
background: #E0E0E0; background: #e0e0e0;
border-radius: 3px; border-radius: 3px;
} }
@@ -350,12 +360,14 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 1000; z-index: 1000;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]::after { [data-tooltip]::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@@ -366,7 +378,9 @@ input[type="range"]::-webkit-slider-thumb {
margin-bottom: 0px; margin-bottom: 0px;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
@@ -464,7 +478,7 @@ button[disabled][data-tooltip]::after {
} }
.segment-tooltip::after { .segment-tooltip::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -6px; bottom: -6px;
left: 50%; left: 50%;
@@ -539,7 +553,7 @@ button[disabled][data-tooltip]::after {
} }
.empty-space-tooltip::after { .empty-space-tooltip::after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -8px; bottom: -8px;
left: 50%; left: 50%;
@@ -617,7 +631,9 @@ button[disabled][data-tooltip]::after {
} }
/* Save buttons styling */ /* Save buttons styling */
.save-button, .save-copy-button, .save-segments-button { .save-button,
.save-copy-button,
.save-segments-button {
background-color: rgba(0, 123, 255, 0.8); background-color: rgba(0, 123, 255, 0.8);
color: white; color: white;
border: none; border: none;
@@ -628,7 +644,8 @@ button[disabled][data-tooltip]::after {
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.save-button:hover, .save-copy-button:hover { .save-button:hover,
.save-copy-button:hover {
background-color: rgba(0, 123, 255, 1); background-color: rgba(0, 123, 255, 1);
} }
@@ -735,7 +752,8 @@ button[disabled][data-tooltip]::after {
font-size: 1.1rem; font-size: 1.1rem;
} }
.current-time, .duration-time { .current-time,
.duration-time {
white-space: nowrap; white-space: nowrap;
} }
@@ -770,7 +788,8 @@ button[disabled][data-tooltip]::after {
gap: 8px; gap: 8px;
} }
.save-button, .save-copy-button { .save-button,
.save-copy-button {
margin-top: 8px; margin-top: 8px;
width: 100%; width: 100%;
} }

View File

@@ -7,25 +7,25 @@ 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;

View File

@@ -10,13 +10,13 @@ async function throwIfResNotOk(res: Response) {
export async function apiRequest( export async function apiRequest(
method: string, method: string,
url: string, url: string,
data?: unknown | undefined, data?: unknown | undefined
): Promise<Response> { ): Promise<Response> {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: data ? { "Content-Type": "application/json" } : {}, headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,
credentials: "include", credentials: "include"
}); });
await throwIfResNotOk(res); await throwIfResNotOk(res);
@@ -24,13 +24,11 @@ export async function apiRequest(
} }
type UnauthorizedBehavior = "returnNull" | "throw"; type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, { const res = await fetch(queryKey[0] as string, {
credentials: "include", credentials: "include"
}); });
if (unauthorizedBehavior === "returnNull" && res.status === 401) { if (unauthorizedBehavior === "returnNull" && res.status === 401) {
@@ -48,10 +46,10 @@ export const queryClient = new QueryClient({
refetchInterval: false, refetchInterval: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTime: Infinity, staleTime: Infinity,
retry: false, retry: false
}, },
mutations: { mutations: {
retry: false, retry: false
}, }
}, }
}); });

View File

@@ -3,17 +3,17 @@
*/ */
export const formatDetailedTime = (seconds: number): string => { export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000"; if (isNaN(seconds)) return "00:00:00.000";
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60); const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000); const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0"); const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0"); const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, "0"); const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, "0"); const formattedMilliseconds = String(milliseconds).padStart(3, "0");
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`; return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
}; };

View File

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

View File

@@ -2,20 +2,17 @@
* Generate a solid color background for a segment * Generate a solid color background for a segment
* Returns a CSS color based on the segment position * Returns a CSS color based on the segment position
*/ */
export const generateSolidColor = ( export const generateSolidColor = (time: number, duration: number): string => {
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}%)`;
}; };
@@ -24,27 +21,27 @@ export const generateSolidColor = (
* 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, videoElement: HTMLVideoElement,
time: number time: number
): Promise<string> => { ): Promise<string> => {
return new Promise((resolve) => { return new Promise((resolve) => {
// Create a small canvas for the solid color // Create a small canvas for the solid color
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
canvas.width = 10; // Much smaller - we only need a color canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10; canvas.height = 10;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
// Get the solid color based on time // Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration); const color = generateSolidColor(time, videoElement.duration);
// 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);
}); });
}; };

View File

@@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.MEDIA_DATA = { window.MEDIA_DATA = {
videoUrl: "", videoUrl: "",
mediaId: "" mediaId: ""
@@ -30,8 +30,8 @@ const mountComponents = () => {
} }
}; };
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', mountComponents); document.addEventListener("DOMContentLoaded", mountComponents);
} else { } else {
mountComponents(); mountComponents();
} }

View File

@@ -4,36 +4,36 @@ 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 // For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later // This can be replaced with actual API calls later
export const trimVideo = async ( export const trimVideo = async (
mediaId: string, mediaId: string,
data: TrimVideoRequest data: TrimVideoRequest
): Promise<TrimVideoResponse> => { ): Promise<TrimVideoResponse> => {
try { try {
// Attempt the real API call // Attempt the real API call
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, { const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
if (!response.ok) { if (!response.ok) {
// For error responses, return with error status and message // For error responses, return with error status and message
if (response.status === 400) { if (response.status === 400) {
@@ -86,7 +86,7 @@ export const trimVideo = async (
}; };
} }
} }
// Successful response // Successful response
const jsonResponse = await response.json(); const jsonResponse = await response.json();
return { return {
@@ -104,7 +104,7 @@ export const trimVideo = async (
url_redirect: `./view?m=${mediaId}` url_redirect: `./view?m=${mediaId}`
}; };
} }
/* Mock implementation that simulates network latency /* Mock implementation that simulates network latency
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
@@ -115,4 +115,4 @@ export const trimVideo = async (
}, 1500); // Simulate 1.5 second server delay }, 1500); // Simulate 1.5 second server delay
}); });
*/ */
}; };

View File

@@ -4,7 +4,7 @@
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
} }
[data-tooltip]:before { [data-tooltip]:before {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
@@ -21,13 +21,15 @@
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:after { [data-tooltip]:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@@ -37,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:before,
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
} }
/* Hide button tooltips on touch devices */ /* Hide button tooltips on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
[data-tooltip]:before, [data-tooltip]:before,
@@ -143,7 +147,9 @@
border-radius: 9999px; border-radius: 9999px;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s, color 0.2s; transition:
background-color 0.2s,
color 0.2s;
min-width: auto; min-width: auto;
&:hover { &:hover {
@@ -163,12 +169,28 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); } .segment-color-1 {
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); } background-color: rgba(59, 130, 246, 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-2 {
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); } background-color: rgba(16, 185, 129, 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-3 {
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); } 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);
}
}

View File

@@ -1,11 +1,10 @@
#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 { [data-tooltip]:before {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
@@ -22,13 +21,15 @@
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:after { [data-tooltip]:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@@ -38,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:before,
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
} }
/* Hide button tooltips on touch devices */ /* Hide button tooltips on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
[data-tooltip]:before, [data-tooltip]:before,
@@ -86,7 +89,7 @@
.full-text { .full-text {
display: inline; display: inline;
} }
.short-text { .short-text {
display: none; display: none;
} }
@@ -99,20 +102,20 @@
.button-group { .button-group {
display: flex; display: flex;
align-items: center; align-items: center;
&.play-buttons-group { &.play-buttons-group {
gap: 0.75rem; gap: 0.75rem;
justify-content: flex-start; justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */ flex: 0 0 auto; /* Don't expand to fill space */
} }
&.secondary { &.secondary {
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
margin-left: auto; /* Push to right edge */ margin-left: auto; /* Push to right edge */
} }
button { button {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -121,17 +124,16 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
min-width: auto; min-width: auto;
/* Disabled hover effect as requested */
&:hover:not(:disabled) { &:hover:not(:disabled) {
color: inherit; color: inherit;
} }
&:disabled { &:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
svg { svg {
height: 1.25rem; height: 1.25rem;
width: 1.25rem; width: 1.25rem;
@@ -144,10 +146,11 @@
border-right: 1px solid #d1d5db; border-right: 1px solid #d1d5db;
height: 1.5rem; height: 1.5rem;
margin: 0 0.5rem; margin: 0 0.5rem;
} }
/* Style for play buttons with highlight effect */ /* Style for play buttons with highlight effect */
.play-button, .preview-button { .play-button,
.preview-button {
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -157,13 +160,13 @@
justify-content: center; justify-content: center;
font-size: 0.875rem !important; font-size: 0.875rem !important;
} }
/* Greyed out play button when segments are playing */ /* Greyed out play button when segments are playing */
.play-button.greyed-out { .play-button.greyed-out {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Highlighted stop button with blue pulse on small screens */ /* Highlighted stop button with blue pulse on small screens */
.segments-button.highlighted-stop { .segments-button.highlighted-stop {
background-color: rgba(59, 130, 246, 0.1); background-color: rgba(59, 130, 246, 0.1);
@@ -171,7 +174,7 @@
border: 1px solid #3b82f6; border: 1px solid #3b82f6;
animation: bluePulse 2s infinite; animation: bluePulse 2s infinite;
} }
@keyframes bluePulse { @keyframes bluePulse {
0% { 0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
@@ -183,9 +186,10 @@
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
} }
} }
/* Completely disable ALL hover effects for play buttons */ /* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled), .preview-button:hover:not(:disabled) { .play-button:hover:not(:disabled),
.preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */ /* Reset everything to prevent any changes */
color: inherit !important; color: inherit !important;
transform: none !important; transform: none !important;
@@ -193,27 +197,15 @@
width: auto !important; width: auto !important;
background: none !important; background: none !important;
} }
.play-button svg, .preview-button svg { .play-button svg,
.preview-button svg {
height: 1.5rem; height: 1.5rem;
width: 1.5rem; width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */ /* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0; flex-shrink: 0;
} }
/* Style for the preview mode message that replaces the play button */
.preview-mode-message {
display: flex;
align-items: center;
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse { @keyframes pulse {
0% { 0% {
opacity: 0.8; opacity: 0.8;
@@ -225,19 +217,12 @@
opacity: 0.8; opacity: 0.8;
} }
} }
.preview-mode-message svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.5rem;
color: #3b82f6;
}
/* Add responsive button text class */ /* Add responsive button text class */
.button-text { .button-text {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
/* Media queries for the editing tools */ /* Media queries for the editing tools */
@media (max-width: 992px) { @media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */ /* Hide text for undo/redo buttons on medium screens */
@@ -245,76 +230,77 @@
display: none; display: none;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */ /* Keep all buttons in a single row, make them more compact */
.flex-container.single-row { .flex-container.single-row {
justify-content: space-between; justify-content: space-between;
} }
.button-group { .button-group {
gap: 0.5rem; gap: 0.5rem;
} }
/* Keep font size consistent regardless of screen size */ /* Keep font size consistent regardless of screen size */
.preview-button, .play-button { .preview-button,
.play-button {
font-size: 0.875rem !important; font-size: 0.875rem !important;
} }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
/* Prevent container overflow on mobile */ /* Prevent container overflow on mobile */
.editing-tools-container { .editing-tools-container {
padding: 0.75rem; padding: 0.75rem;
overflow-x: hidden; overflow-x: hidden;
} }
/* At this breakpoint, make preview button text shorter */ /* At this breakpoint, make preview button text shorter */
.preview-button { .preview-button {
min-width: auto; min-width: auto;
} }
/* Switch to short text versions */ /* Switch to short text versions */
.full-text { .full-text {
display: none; display: none;
} }
.short-text { .short-text {
display: inline; display: inline;
margin-left: 0.15rem; margin-left: 0.15rem;
} }
/* Hide reset text */ /* Hide reset text */
.reset-text { .reset-text {
display: none; display: none;
} }
/* Ensure buttons stay in correct position */ /* Ensure buttons stay in correct position */
.button-group.play-buttons-group { .button-group.play-buttons-group {
flex: initial; flex: initial;
justify-content: flex-start; justify-content: flex-start;
flex-shrink: 0; flex-shrink: 0;
} }
.button-group.secondary { .button-group.secondary {
flex: initial; flex: initial;
justify-content: flex-end; justify-content: flex-end;
flex-shrink: 0; flex-shrink: 0;
} }
/* Reduce button sizes on mobile */ /* Reduce button sizes on mobile */
.button-group button { .button-group button {
padding: 0.375rem; padding: 0.375rem;
min-width: auto; min-width: auto;
} }
.button-group button svg { .button-group button svg {
height: 1.125rem; height: 1.125rem;
width: 1.125rem; width: 1.125rem;
margin-right: 0.125rem; margin-right: 0.125rem;
} }
} }
@media (max-width: 576px) { @media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */ /* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row { .flex-container.single-row {
@@ -322,94 +308,88 @@
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 10px; gap: 10px;
} }
/* Fix left-align for play buttons */ /* Fix left-align for play buttons */
.button-group.play-buttons-group { .button-group.play-buttons-group {
justify-content: flex-start; justify-content: flex-start;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* Fix right-align for editing controls */ /* Fix right-align for editing controls */
.button-group.secondary { .button-group.secondary {
justify-content: flex-end; justify-content: flex-end;
margin-left: auto; margin-left: auto;
} }
/* Reduce button padding to fit more easily */ /* Reduce button padding to fit more easily */
.button-group button { .button-group button {
padding: 0.25rem; padding: 0.25rem;
} }
/* Smaller preview mode message */
.preview-mode-message {
font-size: 0.8rem;
padding: 4px 8px;
}
.divider { .divider {
margin: 0 0.25rem; margin: 0 0.25rem;
} }
} }
/* Very small screens - maintain layout but reduce further */ /* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) { @media (max-width: 480px) {
.editing-tools-container { .editing-tools-container {
padding: 0.5rem; padding: 0.5rem;
} }
.flex-container.single-row { .flex-container.single-row {
gap: 8px; gap: 8px;
} }
.button-group.play-buttons-group, .button-group.play-buttons-group,
.button-group.secondary { .button-group.secondary {
gap: 0.25rem; gap: 0.25rem;
} }
.divider { .divider {
display: none; /* Hide divider on very small screens */ display: none; /* Hide divider on very small screens */
} }
/* Even smaller buttons on very small screens */ /* Even smaller buttons on very small screens */
.button-group button { .button-group button {
padding: 0.125rem; padding: 0.125rem;
} }
.button-group button svg { .button-group button svg {
height: 1rem; height: 1rem;
width: 1rem; width: 1rem;
margin-right: 0; margin-right: 0;
} }
/* Hide all button text on very small screens */ /* Hide all button text on very small screens */
.button-text, .button-text,
.reset-text { .reset-text {
display: none; display: none;
} }
} }
/* Portrait orientation specific fixes */ /* Portrait orientation specific fixes */
@media (max-width: 640px) and (orientation: portrait) { @media (max-width: 640px) and (orientation: portrait) {
.editing-tools-container { .editing-tools-container {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.flex-container.single-row { .flex-container.single-row {
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
/* Ensure button groups don't overflow */ /* Ensure button groups don't overflow */
.button-group { .button-group {
max-width: 50%; max-width: 50%;
} }
.button-group.play-buttons-group { .button-group.play-buttons-group {
max-width: 60%; max-width: 60%;
} }
.button-group.secondary { .button-group.secondary {
max-width: 40%; max-width: 40%;
} }

View File

@@ -132,7 +132,7 @@
.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;
} }
@@ -143,11 +143,11 @@
.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;
@@ -164,4 +164,4 @@ html.ios-device {
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;
} }

View File

@@ -93,4 +93,4 @@
/* Extra spacing for mobile */ /* Extra spacing for mobile */
padding: 14px 25px; padding: 14px 25px;
} }
} }

View File

@@ -36,13 +36,13 @@
.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;
@@ -76,19 +76,19 @@
/* 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;
} }

View File

@@ -1,302 +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;
}
.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;
} }
} }
.error-message {
color: #F44336;
font-weight: 500;
background-color: rgba(244, 67, 54, 0.1);
padding: 10px;
border-radius: 4px;
border-left: 4px solid #F44336;
margin-top: 10px;
}
.redirect-message {
margin-top: 20px;
color: #555;
font-size: 0.95rem;
padding: 0;
margin: 0;
}
.countdown {
font-weight: bold;
color: #0066cc;
font-size: 1.1rem;
}
}

View File

@@ -56,7 +56,7 @@
.timeline-marker { .timeline-marker {
position: absolute; position: absolute;
height: 82px; /* Increased height to extend below timeline */ height: 82px; /* Increased height to extend below timeline */
width: 2px; width: 2px;
background-color: #000; background-color: #000;
transform: translateX(-50%); transform: translateX(-50%);
@@ -83,7 +83,7 @@
.timeline-marker-drag { .timeline-marker-drag {
position: absolute; position: absolute;
bottom: -12px; /* Changed from -6px to -12px to move it further down */ bottom: -12px; /* Changed from -6px to -12px to move it further down */
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 16px; width: 16px;
@@ -248,14 +248,14 @@
right: 0; right: 0;
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
} }
/* Enhanced handles for touch devices */ /* Enhanced handles for touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
.clip-segment-handle { .clip-segment-handle {
width: 14px; /* Wider target for touch devices */ width: 14px; /* Wider target for touch devices */
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */ background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
} }
.clip-segment-handle:after { .clip-segment-handle:after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -267,15 +267,15 @@
background-color: rgba(255, 255, 255, 0.8); background-color: rgba(255, 255, 255, 0.8);
border-radius: 1px; border-radius: 1px;
} }
.clip-segment-handle.left:after { .clip-segment-handle.left:after {
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5); box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
} }
.clip-segment-handle.right:after { .clip-segment-handle.right:after {
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5); box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
} }
/* Active state for touch feedback */ /* Active state for touch feedback */
.clip-segment-handle:active { .clip-segment-handle:active {
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
@@ -284,19 +284,19 @@
.timeline-marker { .timeline-marker {
height: 85px; height: 85px;
} }
.timeline-marker-head { .timeline-marker-head {
width: 24px; width: 24px;
height: 24px; height: 24px;
top: -13px; top: -13px;
} }
.timeline-marker-drag { .timeline-marker-drag {
width: 24px; width: 24px;
height: 24px; height: 24px;
bottom: -18px; bottom: -18px;
} }
.timeline-marker-head.dragging { .timeline-marker-head.dragging {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -321,7 +321,7 @@
.segment-tooltip:after, .segment-tooltip:after,
.empty-space-tooltip:after { .empty-space-tooltip:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
left: 50%; left: 50%;
@@ -335,7 +335,7 @@
.segment-tooltip:before, .segment-tooltip:before,
.empty-space-tooltip:before { .empty-space-tooltip:before {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -6px; bottom: -6px;
left: 50%; left: 50%;
@@ -438,7 +438,7 @@
font-size: 0.875rem; font-size: 0.875rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
margin-right: 0.50rem; margin-right: 0.5rem;
} }
.time-button:hover { .time-button:hover {
@@ -532,8 +532,8 @@
} }
/* General styles for all save buttons */ /* General styles for all save buttons */
.save-button, .save-button,
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
color: #ffffff; color: #ffffff;
background: #0066cc; background: #0066cc;
@@ -548,8 +548,8 @@
} }
/* Shared hover effect */ /* Shared hover effect */
.save-button:hover, .save-button:hover,
.save-copy-button:hover, .save-copy-button:hover,
.save-segments-button:hover { .save-segments-button:hover {
background-color: #0056b3; background-color: #0056b3;
} }
@@ -561,30 +561,30 @@
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
} }
.save-button, .save-button,
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
flex: 1; flex: 1;
font-size: 0.7rem; font-size: 0.7rem;
padding: 0.25rem 0.35rem; padding: 0.25rem 0.35rem;
} }
} }
/* Very small screens - adjust save buttons */ /* Very small screens - adjust save buttons */
@media (max-width: 480px) { @media (max-width: 480px) {
.save-button, .save-button,
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
font-size: 0.675rem; font-size: 0.675rem;
padding: 0.25rem; padding: 0.25rem;
} }
/* Remove margins for controls-right buttons */ /* Remove margins for controls-right buttons */
.controls-right { .controls-right {
margin: 0; margin: 0;
} }
.controls-right button { .controls-right button {
margin: 0; margin: 0;
} }
@@ -595,7 +595,7 @@
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
} }
[data-tooltip]:before { [data-tooltip]:before {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
@@ -612,13 +612,15 @@
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:after { [data-tooltip]:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@@ -628,17 +630,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:before,
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
} }
/* Hide button tooltips on touch devices */ /* Hide button tooltips on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
[data-tooltip]:before, [data-tooltip]:before,
@@ -669,27 +673,27 @@
} }
.modal-success-icon svg { .modal-success-icon svg {
color: #4CAF50; color: #4caf50;
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
} }
.modal-error-icon svg { .modal-error-icon svg {
color: #F44336; color: #f44336;
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
} }
.success-link { .success-link {
background-color: #4CAF50; background-color: #4caf50;
color: white; color: white;
transition: background-color 0.3s; transition: background-color 0.3s;
} }
.success-link:hover { .success-link:hover {
background-color: #388E3C; background-color: #388e3c;
} }
.error-message { .error-message {
color: #F44336; color: #f44336;
font-weight: 500; font-weight: 500;
} }
@@ -809,47 +813,18 @@
} }
@keyframes pulse { @keyframes pulse {
0% { opacity: 0.7; transform: scale(1); } 0% {
50% { opacity: 1; transform: scale(1.05); } opacity: 0.7;
100% { opacity: 0.7; transform: scale(1); } transform: scale(1);
} }
50% {
/* Preview mode styles */ opacity: 1;
.preview-mode .tooltip-action-btn { transform: scale(1.05);
opacity: 0.5; }
pointer-events: none; 100% {
cursor: not-allowed; opacity: 0.7;
} transform: scale(1);
}
.preview-mode .tooltip-time-btn {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
/* Timeline preview mode styles */
.timeline-container-card.preview-mode {
pointer-events: none;
}
.timeline-container-card.preview-mode .timeline-marker-head,
.timeline-container-card.preview-mode .timeline-marker-drag,
.timeline-container-card.preview-mode .clip-segment,
.timeline-container-card.preview-mode .clip-segment-handle,
.timeline-container-card.preview-mode .time-button,
.timeline-container-card.preview-mode .zoom-button,
.timeline-container-card.preview-mode .save-button,
.timeline-container-card.preview-mode .save-copy-button,
.timeline-container-card.preview-mode .save-segments-button {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.timeline-container-card.preview-mode .clip-segment:hover {
box-shadow: none;
border-color: rgba(0, 0, 0, 0.15);
background-color: inherit !important;
} }
/* Segments playback mode styles - minimal functional styling */ /* Segments playback mode styles - minimal functional styling */
@@ -858,19 +833,26 @@
cursor: pointer; cursor: pointer;
} }
.segments-playback-mode .tooltip-action-btn.set-in,
.segments-playback-mode .tooltip-action-btn.set-out,
.segments-playback-mode .tooltip-action-btn.play-from-start {
opacity: 0.5;
pointer-events: none;
}
.segments-playback-mode .tooltip-action-btn.play, .segments-playback-mode .tooltip-action-btn.play,
.segments-playback-mode .tooltip-action-btn.pause { .segments-playback-mode .tooltip-action-btn.pause {
opacity: 1; opacity: 1;
cursor: pointer; cursor: pointer;
} }
/* During segments playback mode, disable button interactions but keep hover working */
.segments-playback-mode .tooltip-time-btn[disabled],
.segments-playback-mode .tooltip-action-btn[disabled] {
opacity: 0.5 !important;
cursor: not-allowed !important;
}
/* Ensure disabled buttons still show tooltips on hover */
.segments-playback-mode [data-tooltip][disabled]:hover:before,
.segments-playback-mode [data-tooltip][disabled]:hover:after {
opacity: 1 !important;
visibility: visible !important;
}
/* Show segments playback message */ /* Show segments playback message */
.segments-playback-message { .segments-playback-message {
display: flex; display: flex;
@@ -889,4 +871,4 @@
width: 1.25rem; width: 1.25rem;
margin-right: 0.5rem; margin-right: 0.5rem;
color: #3b82f6; color: #3b82f6;
} }

View File

@@ -23,7 +23,7 @@
} }
.tooltip-row:first-child { .tooltip-row:first-child {
margin-bottom: 6px; margin-bottom: 6px;
} }
.tooltip-time-btn { .tooltip-time-btn {
@@ -56,6 +56,26 @@
overflow: hidden !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 { .tooltip-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -69,13 +89,13 @@
background-color: #f3f4f6; background-color: #f3f4f6;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 5px; padding: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
color: #4b5563; color: #4b5563;
width: 26px; width: 26px;
height: 26px; height: 26px;
min-width: 20px !important; min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */ position: relative; /* Add relative positioning for tooltips */
@@ -100,14 +120,16 @@
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */ z-index: 2500; /* High z-index */
pointer-events: none; pointer-events: none;
} }
/* Triangle arrow pointing up to the button */ /* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after { .tooltip-action-btn[data-tooltip]:after {
content: ''; content: "";
position: absolute; position: absolute;
top: 35px; /* Match the before element */ top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */ left: 50%; /* Center horizontally */
@@ -119,7 +141,9 @@
margin-left: 0; /* Reset margin */ margin-left: 0; /* Reset margin */
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 2500; /* High z-index */ z-index: 2500; /* High z-index */
pointer-events: none; pointer-events: none;
} }
@@ -175,7 +199,7 @@
} }
.tooltip-action-btn.play-from-start { .tooltip-action-btn.play-from-start {
color: #4f46e5; color: #4f46e5;
} }
.tooltip-action-btn.play-from-start:hover { .tooltip-action-btn.play-from-start:hover {
@@ -194,7 +218,7 @@
padding: 6px 10px; padding: 6px 10px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
color: #10b981; color: #10b981;
} }
.tooltip-action-btn.new-segment:hover { .tooltip-action-btn.new-segment:hover {
@@ -227,43 +251,80 @@
color: #9ca3af; 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 */ /* Additional mobile optimizations */
@media (max-width: 768px) { @media (max-width: 768px) {
.two-row-tooltip { .two-row-tooltip {
padding: 4px; padding: 4px;
} }
.tooltip-row:first-child { .tooltip-row:first-child {
margin-bottom: 4px; margin-bottom: 4px;
} }
.tooltip-time-btn { .tooltip-time-btn {
min-width: 20px !important; min-width: 20px !important;
font-size: 0.7rem !important; font-size: 0.7rem !important;
padding: 3px 6px !important; padding: 3px 6px !important;
} }
.tooltip-time-display { .tooltip-time-display {
font-size: 0.8rem !important; font-size: 0.8rem !important;
padding: 3px 4px !important; padding: 3px 4px !important;
min-width: 90px !important; min-width: 90px !important;
} }
.tooltip-action-btn { .tooltip-action-btn {
width: 24px; width: 24px;
height: 24px; height: 24px;
padding: 4px; padding: 4px;
} }
.tooltip-action-btn.new-segment { .tooltip-action-btn.new-segment {
padding: 4px 8px; padding: 4px 8px;
} }
.tooltip-action-btn svg { .tooltip-action-btn svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Adjust tooltip position for small screens - maintain the same position but adjust size */ /* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before { .tooltip-action-btn[data-tooltip]:before {
min-width: 100px; min-width: 100px;
@@ -272,7 +333,7 @@
height: 24px; height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */ top: 33px; /* Maintain the same relative distance on mobile */
} }
.tooltip-action-btn[data-tooltip]:after { .tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */ top: 33px; /* Match the tooltip position */
} }

View File

@@ -4,7 +4,7 @@
[data-tooltip] { [data-tooltip] {
position: relative; position: relative;
} }
[data-tooltip]:before { [data-tooltip]:before {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
@@ -21,13 +21,15 @@
white-space: nowrap; white-space: nowrap;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:after { [data-tooltip]:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
@@ -37,17 +39,19 @@
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent; border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity 0.2s, visibility 0.2s; transition:
opacity 0.2s,
visibility 0.2s;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:before,
[data-tooltip]:hover:after { [data-tooltip]:hover:after {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
} }
} }
/* Hide button tooltips on touch devices */ /* Hide button tooltips on touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
[data-tooltip]:before, [data-tooltip]:before,
@@ -71,7 +75,7 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.video-player-container video { .video-player-container video {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -83,7 +87,7 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
/* iOS-specific styles */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.video-player-container video { .video-player-container video {
@@ -92,7 +96,7 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
} }
.play-pause-indicator { .play-pause-indicator {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -106,19 +110,19 @@
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
} }
.video-player-container:hover .play-pause-indicator { .video-player-container:hover .play-pause-indicator {
opacity: 1; opacity: 1;
} }
.play-pause-indicator::before { .play-pause-indicator::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.play-pause-indicator.play-icon::before { .play-pause-indicator.play-icon::before {
width: 0; width: 0;
height: 0; height: 0;
@@ -127,14 +131,14 @@
border-left: 25px solid white; border-left: 25px solid white;
margin-left: 3px; margin-left: 3px;
} }
.play-pause-indicator.pause-icon::before { .play-pause-indicator.pause-icon::before {
width: 20px; width: 20px;
height: 25px; height: 25px;
border-left: 6px solid white; border-left: 6px solid white;
border-right: 6px solid white; border-right: 6px solid white;
} }
/* iOS First-play indicator */ /* iOS First-play indicator */
.ios-first-play-indicator { .ios-first-play-indicator {
position: absolute; position: absolute;
@@ -148,7 +152,7 @@
justify-content: center; justify-content: center;
z-index: 10; z-index: 10;
} }
.ios-play-message { .ios-play-message {
color: white; color: white;
font-size: 1.2rem; font-size: 1.2rem;
@@ -158,13 +162,22 @@
border-radius: 0.5rem; border-radius: 0.5rem;
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
@keyframes pulse { @keyframes pulse {
0% { opacity: 0.7; transform: scale(1); } 0% {
50% { opacity: 1; transform: scale(1.05); } opacity: 0.7;
100% { opacity: 0.7; transform: scale(1); } transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
100% {
opacity: 0.7;
transform: scale(1);
}
} }
.video-controls { .video-controls {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -175,21 +188,21 @@
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
} }
.video-player-container:hover .video-controls { .video-player-container:hover .video-controls {
opacity: 1; opacity: 1;
} }
.video-current-time { .video-current-time {
color: white; color: white;
font-size: 0.875rem; font-size: 0.875rem;
} }
.video-duration { .video-duration {
color: white; color: white;
font-size: 0.875rem; font-size: 0.875rem;
} }
.video-time-display { .video-time-display {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -197,7 +210,7 @@
color: white; color: white;
font-size: 0.875rem; font-size: 0.875rem;
} }
.video-progress { .video-progress {
position: relative; position: relative;
height: 6px; height: 6px;
@@ -208,11 +221,11 @@
touch-action: none; /* Prevent browser handling of drag gestures */ touch-action: none; /* Prevent browser handling of drag gestures */
flex-grow: 1; flex-grow: 1;
} }
.video-progress.dragging { .video-progress.dragging {
height: 8px; height: 8px;
} }
.video-progress-fill { .video-progress-fill {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -222,7 +235,7 @@
border-radius: 3px; border-radius: 3px;
pointer-events: none; pointer-events: none;
} }
.video-scrubber { .video-scrubber {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -232,9 +245,12 @@
background-color: #ff0000; background-color: #ff0000;
border-radius: 50%; border-radius: 50%;
cursor: grab; cursor: grab;
transition: transform 0.1s ease, width 0.1s ease, height 0.1s ease; transition:
transform 0.1s ease,
width 0.1s ease,
height 0.1s ease;
} }
/* Make the scrubber larger when dragging for better control */ /* Make the scrubber larger when dragging for better control */
.video-progress.dragging .video-scrubber { .video-progress.dragging .video-scrubber {
transform: translate(-50%, -50%) scale(1.2); transform: translate(-50%, -50%) scale(1.2);
@@ -243,22 +259,22 @@
cursor: grabbing; cursor: grabbing;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6); box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
} }
/* Enhance for touch devices */ /* Enhance for touch devices */
@media (pointer: coarse) { @media (pointer: coarse) {
.video-scrubber { .video-scrubber {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
.video-progress.dragging .video-scrubber { .video-progress.dragging .video-scrubber {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
/* Create a larger invisible touch target */ /* Create a larger invisible touch target */
.video-scrubber:before { .video-scrubber:before {
content: ''; content: "";
position: absolute; position: absolute;
top: -10px; top: -10px;
left: -10px; left: -10px;
@@ -266,14 +282,14 @@
bottom: -10px; bottom: -10px;
} }
} }
.video-controls-buttons { .video-controls-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
} }
.mute-button, .mute-button,
.fullscreen-button { .fullscreen-button {
min-width: auto; min-width: auto;
@@ -283,17 +299,17 @@
cursor: pointer; cursor: pointer;
padding: 0.25rem; padding: 0.25rem;
transition: transform 0.2s; transition: transform 0.2s;
&:hover { &:hover {
transform: scale(1.1); transform: scale(1.1);
} }
svg { svg {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
} }
} }
/* Time tooltip that appears when dragging */ /* Time tooltip that appears when dragging */
.video-time-tooltip { .video-time-tooltip {
position: absolute; position: absolute;
@@ -309,10 +325,10 @@
white-space: nowrap; white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }
/* Add a small arrow to the tooltip */ /* Add a small arrow to the tooltip */
.video-time-tooltip:after { .video-time-tooltip:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -4px; bottom: -4px;
left: 50%; left: 50%;
@@ -323,4 +339,4 @@
border-right: 4px solid transparent; border-right: 4px solid transparent;
border-top: 4px solid rgba(0, 0, 0, 0.7); border-top: 4px solid rgba(0, 0, 0, 0.7);
} }
} }

View File

@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"check": "tsc", "check": "tsc",
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor" "build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor",
"format": "npx prettier --write client/src/**/*.{ts,tsx,css}"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
@@ -35,6 +36,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.6.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^5.4.18" "vite": "^5.4.18"

View File

@@ -1834,6 +1834,11 @@ postcss@^8.4.43, postcss@^8.4.47:
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.1" source-map-js "^1.2.1"
prettier@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.0.tgz#18ec98d62cb0757a5d4eab40253ff3e6d0fc8dea"
integrity sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==
proxy-addr@~2.0.7: proxy-addr@~2.0.7:
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -2087,6 +2092,7 @@ statuses@2.0.1:
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -2105,6 +2111,7 @@ string-width@^5.0.1, string-width@^5.1.2:
strip-ansi "^7.0.1" strip-ansi "^7.0.1"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==

View File

@@ -2,7 +2,7 @@ Django==5.1.6
djangorestframework==3.15.2 djangorestframework==3.15.2
python3-saml==1.16.0 python3-saml==1.16.0
django-allauth==65.4.1 django-allauth==65.4.1
psycopg==3.2.4 psycopg[pool]==3.2.4
uwsgi==2.0.28 uwsgi==2.0.28
django-redis==5.4.0 django-redis==5.4.0
celery==5.4.0 celery==5.4.0
@@ -18,7 +18,6 @@ requests==2.32.3
django-celery-email==3.0.0 django-celery-email==3.0.0
m3u8==6.0.0 m3u8==6.0.0
django-debug-toolbar==5.0.1 django-debug-toolbar==5.0.1
django-login-required-middleware==0.9.0
pre-commit==4.1.0 pre-commit==4.1.0
django-jazzmin==3.0.1 django-jazzmin==3.0.1
pysubs2==1.8.0 pysubs2==1.8.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,3 @@
{% load static %} {% load static %}
<script src="{% static "js/_commons.js" %}"></script> <script src="{% static "js/_commons.js" %}?v={{ VERSION }}"></script>

View File

@@ -22,10 +22,10 @@
<link href="{% static "lib/gfonts/gfonts.css" %}" rel="stylesheet"> <link href="{% static "lib/gfonts/gfonts.css" %}" rel="stylesheet">
{% endif %} {% endif %}
<link href="{% static "css/_commons.css" %}" rel="preload" as="style"> <link href="{% static "css/_commons.css" %}?v={{ VERSION }}" rel="preload" as="style">
<link href="{% static "css/_commons.css" %}" rel="stylesheet"> <link href="{% static "css/_commons.css" %}?v={{ VERSION }}" rel="stylesheet">
<link href="{% static "css/_extra.css" %}" rel="preload" as="style"> <link href="{% static "css/_extra.css" %}?v={{ VERSION }}" rel="preload" as="style">
<link href="{% static "css/_extra.css" %}" rel="stylesheet"> <link href="{% static "css/_extra.css" %}?v={{ VERSION }}" rel="stylesheet">
<link href="{% static "js/_commons.js" %}" rel="preload" as="script"> <link href="{% static "js/_commons.js" %}?v={{ VERSION }}" rel="preload" as="script">

View File

@@ -43,8 +43,8 @@ class TestX(TestCase):
self.assertEqual(Media.objects.filter(media_type='image').count(), 1, "Media identification failed") self.assertEqual(Media.objects.filter(media_type='image').count(), 1, "Media identification failed")
self.assertEqual(Media.objects.filter(user=self.user).count(), 3, "User assignment failed") self.assertEqual(Media.objects.filter(user=self.user).count(), 3, "User assignment failed")
medium_video = Media.objects.get(title="medium_video.mp4") medium_video = Media.objects.get(title="medium_video.mp4")
self.assertEqual(len(medium_video.hls_info), 11, "Problem with HLS info") self.assertEqual(len(medium_video.hls_info), 13, "Problem with HLS info")
# using the provided EncodeProfiles, these two files should produce 9 Encoding objects. # using the provided EncodeProfiles, these two files should produce 9 Encoding objects.
# if new EncodeProfiles are added and enabled, this will break! # if new EncodeProfiles are added and enabled, this will break!
self.assertEqual(Encoding.objects.filter(status='success').count(), 9, "Not all video transcodings finished well") self.assertEqual(Encoding.objects.filter(status='success').count(), 10, "Not all video transcodings finished well")

View File

@@ -24,12 +24,12 @@ class TestFixtures(TestCase):
profiles = EncodeProfile.objects.all() profiles = EncodeProfile.objects.all()
self.assertEqual( self.assertEqual(
profiles.count(), profiles.count(),
22, 23,
"Problem with Encode Profile fixtures", "Problem with Encode Profile fixtures",
) )
profiles = EncodeProfile.objects.filter(active=True) profiles = EncodeProfile.objects.filter(active=True)
self.assertEqual( self.assertEqual(
profiles.count(), profiles.count(),
6, 7,
"Problem with Encode Profile fixtures, not as active as expected", "Problem with Encode Profile fixtures, not as active as expected",
) )

View File

@@ -7,7 +7,7 @@ def import_class(path):
path_bits = path.split(".") path_bits = path.split(".")
if len(path_bits) < 2: if len(path_bits) < 2:
message = "'{0}' is not a complete Python path.".format(path) message = f"'{path}' is not a complete Python path."
raise ImproperlyConfigured(message) raise ImproperlyConfigured(message)
class_name = path_bits.pop() class_name = path_bits.pop()
@@ -15,7 +15,7 @@ def import_class(path):
module_itself = import_module(module_path) module_itself = import_module(module_path)
if not hasattr(module_itself, class_name): if not hasattr(module_itself, class_name):
message = "The Python module '{}' has no '{}' class.".format(module_path, class_name) message = f"The Python module '{module_path}' has no '{class_name}' class."
raise ImportError(message) raise ImportError(message)
return getattr(module_itself, class_name) return getattr(module_itself, class_name)

View File

@@ -105,7 +105,7 @@ class User(AbstractUser):
ret = {} ret = {}
results = [] results = []
ret["results"] = results ret["results"] = results
ret["user_media"] = "/api/v1/media?author={0}".format(self.username) ret["user_media"] = f"/api/v1/media?author={self.username}"
return ret return ret
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -210,7 +210,7 @@ class Channel(models.Model):
super(Channel, self).save(*args, **kwargs) super(Channel, self).save(*args, **kwargs)
def __str__(self): def __str__(self):
return "{0} -{1}".format(self.user.username, self.title) return f"{self.user.username} -{self.title}"
def get_absolute_url(self, edit=False): def get_absolute_url(self, edit=False):
if edit: if edit:
@@ -230,7 +230,7 @@ def post_user_create(sender, instance, created, **kwargs):
new = Channel.objects.create(title="default", user=instance) new = Channel.objects.create(title="default", user=instance)
new.save() new.save()
if settings.ADMINS_NOTIFICATIONS.get("NEW_USER", False): if settings.ADMINS_NOTIFICATIONS.get("NEW_USER", False):
title = "[{}] - New user just registered".format(settings.PORTAL_NAME) title = f"[{settings.PORTAL_NAME}] - New user just registered"
msg = """ msg = """
User has just registered with email %s\n User has just registered with email %s\n
Visit user profile page at %s Visit user profile page at %s