mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-21 05:56:03 -05:00
Compare commits
12 Commits
7324a0def7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a90fcbf8dd | ||
|
|
1b3cdfd302 | ||
|
|
cd7dd4f72c | ||
|
|
9b3d9fe1e7 | ||
|
|
ea340b6a2e | ||
|
|
ba2c31b1e6 | ||
|
|
5eb6fafb8c | ||
|
|
c035bcddf5 | ||
|
|
01912ea1f9 | ||
|
|
d9f299af4d | ||
|
|
e80590a3aa | ||
|
|
2a0cb977f2 |
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.0.0
|
||||
rev: 6.1.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/pycqa/isort
|
||||
|
||||
@@ -100,6 +100,9 @@ RELATED_MEDIA_STRATEGY = "content"
|
||||
# Whether or not to generate a sitemap.xml listing the pages on the site (default: False)
|
||||
GENERATE_SITEMAP = False
|
||||
|
||||
# Whether to include media count numbers on categories and tags listing pages
|
||||
INCLUDE_LISTING_NUMBERS = True
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
@@ -567,6 +570,11 @@ ALLOW_ANONYMOUS_USER_LISTING = True
|
||||
# valid choices are all, editors, admins
|
||||
CAN_SEE_MEMBERS_PAGE = "all"
|
||||
|
||||
# User search field setting
|
||||
# valid choices are name_username, name_username_email
|
||||
# this searches for users in the share media modal under my media
|
||||
USER_SEARCH_FIELD = "name_username"
|
||||
|
||||
# Maximum number of media a user can upload
|
||||
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "11.555.0"
|
||||
VERSION = "7.2.2"
|
||||
|
||||
@@ -533,11 +533,49 @@ By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can se
|
||||
- **admins**, only MediaCMS admins can view the page
|
||||
|
||||
|
||||
### 5.28 Require user approval on registration
|
||||
### 5.28 Configure user search fields
|
||||
|
||||
By default, when searching for users (e.g., in bulk actions modals or the users API), the search is performed on the user's name and username. You can configure this behavior using the `USER_SEARCH_FIELD` setting:
|
||||
|
||||
```
|
||||
USER_SEARCH_FIELD = "name_username" # Default - searches in name and username
|
||||
```
|
||||
|
||||
To also include email addresses in the search and display them in the user interface:
|
||||
|
||||
```
|
||||
USER_SEARCH_FIELD = "name_username_email" # Searches in name, username, and email
|
||||
```
|
||||
|
||||
When set to `"name_username_email"`:
|
||||
- The user search will also match email addresses
|
||||
- The email field will be returned in the API response
|
||||
- Frontend components will display users as "Name - Email" instead of "Name - Username"
|
||||
|
||||
This setting is useful when you want to make it easier to find users by their email addresses, particularly in administrative interfaces like bulk action modals.
|
||||
|
||||
|
||||
### 5.29 Require user approval on registration
|
||||
|
||||
By default, users do not require approval, so they can login immediately after registration (if registration is open). However, if the parameter `USERS_NEEDS_TO_BE_APPROVED` is set to `True`, they will first have to have their accounts approved by an administrator before they can successfully sign in.
|
||||
Administrators can approve users through the following ways: 1. through Django administration, 2. through the users management page, 3. through editing the profile page directly. In all cases, set 'Is approved' to True.
|
||||
|
||||
### 5.30 Show or hide media count numbers on categories and tags pages
|
||||
|
||||
By default, the number of media items is displayed next to each category and tag on the `/categories` and `/tags` pages. To hide these numbers:
|
||||
|
||||
```
|
||||
INCLUDE_LISTING_NUMBERS = False
|
||||
```
|
||||
|
||||
To show the numbers (default behavior):
|
||||
|
||||
```
|
||||
INCLUDE_LISTING_NUMBERS = True
|
||||
```
|
||||
|
||||
This setting affects only the visual display on the categories and tags listing pages and does not impact the functionality of filtering by categories or tags.
|
||||
|
||||
|
||||
## 6. Manage pages
|
||||
to be written
|
||||
|
||||
@@ -57,6 +57,7 @@ def stuff(request):
|
||||
ret["USE_SAML"] = settings.USE_SAML
|
||||
ret["USE_RBAC"] = settings.USE_RBAC
|
||||
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
|
||||
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
|
||||
ret["VERSION"] = VERSION
|
||||
|
||||
if request.user.is_superuser:
|
||||
|
||||
@@ -178,14 +178,11 @@ class MediaPublishForm(forms.ModelForm):
|
||||
state = cleaned_data.get("state")
|
||||
categories = cleaned_data.get("category")
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
|
||||
if state in ['private', 'unlisted']:
|
||||
custom_permissions = self.instance.permissions.exists()
|
||||
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
|
||||
|
||||
if rbac_categories and state in ['private', 'unlisted']:
|
||||
# Make the confirm_state field visible and add it to the layout
|
||||
if rbac_categories or custom_permissions:
|
||||
self.fields['confirm_state'].widget = forms.CheckboxInput()
|
||||
|
||||
# add it after the state field
|
||||
state_index = None
|
||||
for i, layout_item in enumerate(self.helper.layout):
|
||||
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
|
||||
@@ -198,7 +195,11 @@ class MediaPublishForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(*layout_items)
|
||||
|
||||
if not cleaned_data.get('confirm_state'):
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
|
||||
if rbac_categories:
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to categories: {', '.join(rbac_categories)}"
|
||||
self.add_error('confirm_state', error_message)
|
||||
if custom_permissions:
|
||||
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
|
||||
self.add_error('confirm_state', error_message)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
@@ -910,7 +910,9 @@ def trim_video_method(media_file_path, timestamps_list):
|
||||
return False
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
output_file = os.path.join(temp_dir, "output.mp4")
|
||||
# Detect input file extension to preserve original format
|
||||
_, input_ext = os.path.splitext(media_file_path)
|
||||
output_file = os.path.join(temp_dir, f"output{input_ext}")
|
||||
|
||||
segment_files = []
|
||||
for i, item in enumerate(timestamps_list):
|
||||
@@ -920,7 +922,7 @@ def trim_video_method(media_file_path, timestamps_list):
|
||||
|
||||
# For single timestamp, we can use the output file directly
|
||||
# For multiple timestamps, we need to create segment files
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
|
||||
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}{input_ext}")
|
||||
|
||||
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
|
||||
|
||||
|
||||
@@ -272,12 +272,16 @@ def show_related_media_content(media, request, limit):
|
||||
category = media.category.first()
|
||||
if category:
|
||||
q_category = Q(listable=True, category=category)
|
||||
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
|
||||
# Fix: Ensure slice index is never negative
|
||||
remaining = max(0, limit - len(m))
|
||||
q_res = models.Media.objects.filter(q_category).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
|
||||
m = list(itertools.chain(m, q_res))
|
||||
|
||||
if len(m) < limit:
|
||||
q_generic = Q(listable=True)
|
||||
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[: limit - media.user.media_count]
|
||||
# Fix: Ensure slice index is never negative
|
||||
remaining = max(0, limit - len(m))
|
||||
q_res = models.Media.objects.filter(q_generic).order_by(order_criteria[random.randint(0, len(order_criteria) - 1)]).prefetch_related("user")[:remaining]
|
||||
m = list(itertools.chain(m, q_res))
|
||||
|
||||
m = list(set(m[:limit])) # remove duplicates
|
||||
@@ -490,7 +494,6 @@ def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
|
||||
state=helpers.get_default_state(user=original_media.user),
|
||||
is_reviewed=original_media.is_reviewed,
|
||||
encoding_status=original_media.encoding_status,
|
||||
listable=original_media.listable,
|
||||
add_date=timezone.now(),
|
||||
video_height=original_media.video_height,
|
||||
size=original_media.size,
|
||||
@@ -666,11 +669,8 @@ def change_media_owner(media_id, new_user):
|
||||
media.user = new_user
|
||||
media.save(update_fields=["user"])
|
||||
|
||||
# Update any related permissions
|
||||
media_permissions = models.MediaPermission.objects.filter(media=media)
|
||||
for permission in media_permissions:
|
||||
permission.owner_user = new_user
|
||||
permission.save(update_fields=["owner_user"])
|
||||
# Optimize: Update any related permissions in bulk instead of loop
|
||||
models.MediaPermission.objects.filter(media=media).update(owner_user=new_user)
|
||||
|
||||
# remove any existing permissions for the new user, since they are now owner
|
||||
models.MediaPermission.objects.filter(media=media, user=new_user).delete()
|
||||
@@ -713,7 +713,6 @@ def copy_media(media):
|
||||
state=helpers.get_default_state(user=media.user),
|
||||
is_reviewed=media.is_reviewed,
|
||||
encoding_status=media.encoding_status,
|
||||
listable=media.listable,
|
||||
add_date=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ class Category(models.Model):
|
||||
if self.listings_thumbnail:
|
||||
return self.listings_thumbnail
|
||||
|
||||
if Media.objects.filter(category=self, state="public").exists():
|
||||
# Optimize: Use first() directly instead of exists() + first() (saves one query)
|
||||
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
||||
if media:
|
||||
return media.thumbnail_url
|
||||
|
||||
@@ -282,7 +282,7 @@ class Media(models.Model):
|
||||
self.allow_whisper_transcribe != self.__original_allow_whisper_transcribe or self.allow_whisper_transcribe_and_translate != self.__original_allow_whisper_transcribe_and_translate
|
||||
)
|
||||
|
||||
if transcription_changed and self.media_type == "video":
|
||||
if transcription_changed and self.media_type in ["video", "audio"]:
|
||||
self.transcribe_function()
|
||||
|
||||
# Update the original values for next comparison
|
||||
@@ -329,10 +329,17 @@ class Media(models.Model):
|
||||
|
||||
if to_transcribe:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
|
||||
tasks.whisper_transcribe.apply_async(
|
||||
args=[self.friendly_token, False],
|
||||
countdown=10,
|
||||
)
|
||||
|
||||
if to_transcribe_and_translate:
|
||||
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
|
||||
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
|
||||
tasks.whisper_transcribe.apply_async(
|
||||
args=[self.friendly_token, True],
|
||||
countdown=10,
|
||||
)
|
||||
|
||||
def update_search_vector(self):
|
||||
"""
|
||||
@@ -763,6 +770,8 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.uploaded_thumbnail.path)
|
||||
if self.thumbnail:
|
||||
return helpers.url_from_path(self.thumbnail.path)
|
||||
if self.media_type == "audio":
|
||||
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -776,6 +785,9 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.uploaded_poster.path)
|
||||
if self.poster:
|
||||
return helpers.url_from_path(self.poster.path)
|
||||
if self.media_type == "audio":
|
||||
return helpers.url_from_path("userlogos/poster_audio.jpg")
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -74,10 +74,8 @@ class MediaList(APIView):
|
||||
if not request.user.is_authenticated:
|
||||
return base_queryset.filter(base_filters)
|
||||
|
||||
# Build OR conditions for authenticated users
|
||||
conditions = base_filters # Start with listable media
|
||||
conditions = base_filters
|
||||
|
||||
# Add user permissions
|
||||
permission_filter = {'user': request.user}
|
||||
if user:
|
||||
permission_filter['owner_user'] = user
|
||||
@@ -88,7 +86,6 @@ class MediaList(APIView):
|
||||
perm_conditions &= Q(user=user)
|
||||
conditions |= perm_conditions
|
||||
|
||||
# Add RBAC conditions
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
rbac_conditions = Q(category__in=rbac_categories)
|
||||
@@ -99,7 +96,6 @@ class MediaList(APIView):
|
||||
return base_queryset.filter(conditions).distinct()
|
||||
|
||||
def get(self, request, format=None):
|
||||
# Show media
|
||||
# authenticated users can see:
|
||||
|
||||
# All listable media (public access)
|
||||
@@ -118,7 +114,6 @@ class MediaList(APIView):
|
||||
publish_state = params.get('publish_state', '').strip()
|
||||
query = params.get("q", "").strip().lower()
|
||||
|
||||
# Handle combined sort options (e.g., title_asc, views_desc)
|
||||
parsed_combined = False
|
||||
if sort_by and '_' in sort_by:
|
||||
parts = sort_by.rsplit('_', 1)
|
||||
@@ -237,14 +232,14 @@ class MediaList(APIView):
|
||||
if not already_sorted:
|
||||
media = media.order_by(f"{ordering}{sort_by}")
|
||||
|
||||
media = media[:1000] # limit to 1000 results
|
||||
media = media[:1000]
|
||||
|
||||
paginator = pagination_class()
|
||||
|
||||
page = paginator.paginate_queryset(media, request)
|
||||
|
||||
serializer = MediaSerializer(page, many=True, context={"request": request})
|
||||
# Collect all unique tags from the current page results
|
||||
|
||||
tags_set = set()
|
||||
for media_obj in page:
|
||||
for tag in media_obj.tags.all():
|
||||
@@ -354,28 +349,23 @@ class MediaBulkUserActions(APIView):
|
||||
},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Check if user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Get required parameters
|
||||
media_ids = request.data.get('media_ids', [])
|
||||
action = request.data.get('action')
|
||||
|
||||
# Validate required parameters
|
||||
if not media_ids:
|
||||
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not action:
|
||||
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get media objects owned by the user
|
||||
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
|
||||
|
||||
if not media:
|
||||
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Process based on action
|
||||
if action == "enable_comments":
|
||||
media.update(enable_comments=True)
|
||||
return Response({"detail": f"Comments enabled for {media.count()} media items"})
|
||||
@@ -446,12 +436,10 @@ class MediaBulkUserActions(APIView):
|
||||
if state not in valid_states:
|
||||
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check if user can set public state
|
||||
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
|
||||
if state == "public":
|
||||
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update media state
|
||||
for m in media:
|
||||
m.state = state
|
||||
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
|
||||
@@ -495,8 +483,6 @@ class MediaBulkUserActions(APIView):
|
||||
if ownership_type not in valid_ownership_types:
|
||||
return Response({"detail": f"ownership_type must be one of {valid_ownership_types}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Find users who have the permission on ALL media items (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
users = (
|
||||
@@ -523,7 +509,6 @@ class MediaBulkUserActions(APIView):
|
||||
if not usernames:
|
||||
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get valid users from the provided usernames
|
||||
users = User.objects.filter(username__in=usernames)
|
||||
if not users.exists():
|
||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -548,22 +533,17 @@ class MediaBulkUserActions(APIView):
|
||||
if not usernames:
|
||||
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get valid users from the provided usernames
|
||||
users = User.objects.filter(username__in=usernames)
|
||||
if not users.exists():
|
||||
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Delete MediaPermission objects matching the criteria
|
||||
MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
|
||||
|
||||
return Response({"detail": "Action succeeded"})
|
||||
|
||||
elif action == "playlist_membership":
|
||||
# Find playlists that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query playlists owned by user that contain these media
|
||||
results = list(
|
||||
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
|
||||
.values('id', 'friendly_token', 'title')
|
||||
@@ -574,21 +554,15 @@ class MediaBulkUserActions(APIView):
|
||||
return Response({'results': results})
|
||||
|
||||
elif action == "category_membership":
|
||||
# Find categories that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query categories that contain these media
|
||||
results = list(Category.objects.filter(media__in=media).values('title', 'uid').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
|
||||
|
||||
return Response({'results': results})
|
||||
|
||||
elif action == "tag_membership":
|
||||
# Find tags that contain ALL the selected media (intersection)
|
||||
|
||||
media_count = media.count()
|
||||
|
||||
# Query tags that contain these media
|
||||
results = list(Tag.objects.filter(media__in=media).values('title').annotate(media_count=Count('media', distinct=True)).filter(media_count=media_count))
|
||||
|
||||
return Response({'results': results})
|
||||
@@ -605,7 +579,6 @@ class MediaBulkUserActions(APIView):
|
||||
added_count = 0
|
||||
for category in categories:
|
||||
for m in media:
|
||||
# Add media to category (ManyToMany relationship)
|
||||
if not m.category.filter(uid=category.uid).exists():
|
||||
m.category.add(category)
|
||||
added_count += 1
|
||||
@@ -624,7 +597,6 @@ class MediaBulkUserActions(APIView):
|
||||
removed_count = 0
|
||||
for category in categories:
|
||||
for m in media:
|
||||
# Remove media from category (ManyToMany relationship)
|
||||
if m.category.filter(uid=category.uid).exists():
|
||||
m.category.remove(category)
|
||||
removed_count += 1
|
||||
@@ -643,7 +615,6 @@ class MediaBulkUserActions(APIView):
|
||||
added_count = 0
|
||||
for tag in tags:
|
||||
for m in media:
|
||||
# Add media to tag (ManyToMany relationship)
|
||||
if not m.tags.filter(title=tag.title).exists():
|
||||
m.tags.add(tag)
|
||||
added_count += 1
|
||||
@@ -662,7 +633,6 @@ class MediaBulkUserActions(APIView):
|
||||
removed_count = 0
|
||||
for tag in tags:
|
||||
for m in media:
|
||||
# Remove media from tag (ManyToMany relationship)
|
||||
if m.tags.filter(title=tag.title).exists():
|
||||
m.tags.remove(tag)
|
||||
removed_count += 1
|
||||
|
||||
@@ -343,6 +343,10 @@ def publish_media(request):
|
||||
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user.has_owner_access_to_media(media) or is_mediacms_editor(request.user)):
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, f"Permission to publish is not grated by the owner: {media.user.name}"))
|
||||
return HttpResponseRedirect(media.get_absolute_url())
|
||||
|
||||
if request.method == "POST":
|
||||
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
|
||||
if form.is_valid():
|
||||
|
||||
@@ -96,7 +96,8 @@ const App = () => {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
@@ -104,7 +105,8 @@ const App = () => {
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.min(videoRef.current.currentTime + 10, duration);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
@@ -117,7 +119,7 @@ const App = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
|
||||
}, [handlePlay, handleMobileSafeSeek, duration, videoRef]);
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
|
||||
@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
setVideoUrl(url);
|
||||
|
||||
// Check if the media is an audio file and set poster image
|
||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
setIsAudioFile(audioFile);
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
@@ -128,10 +130,21 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Video container with persistent background for audio files */}
|
||||
<div className="ios-video-wrapper">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="ios-audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
@@ -144,6 +157,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
|
||||
@@ -268,13 +268,8 @@ const TimelineControls = ({
|
||||
// Update editing title when selected segment changes
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.)
|
||||
const isDefaultChapterName = selectedSegment.chapterTitle &&
|
||||
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
|
||||
|
||||
// If it's a default name, show empty string so placeholder appears
|
||||
// If it's a custom title, show the actual title
|
||||
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
|
||||
// Always show the chapter title in the textarea, whether it's default or custom
|
||||
setEditingChapterTitle(selectedSegment.chapterTitle || '');
|
||||
} else {
|
||||
setEditingChapterTitle('');
|
||||
}
|
||||
@@ -589,12 +584,13 @@ const TimelineControls = ({
|
||||
|
||||
// Update display time and check for transitions between segments and empty spaces
|
||||
useEffect(() => {
|
||||
// Always update display time to match current video time when playing
|
||||
// Always update display time to match current video time
|
||||
if (videoRef.current) {
|
||||
// If video is playing, always update the displayed time in the tooltip
|
||||
if (!videoRef.current.paused) {
|
||||
// Always update display time when current time changes (both playing and paused)
|
||||
setDisplayTime(currentTime);
|
||||
|
||||
// If video is playing, also update the tooltip and perform segment checks
|
||||
if (!videoRef.current.paused) {
|
||||
// Also update clicked time to keep them in sync when playing
|
||||
// This ensures correct time is shown when pausing
|
||||
setClickedTime(currentTime);
|
||||
|
||||
@@ -353,8 +353,18 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
|
||||
@@ -20,7 +20,7 @@ const useVideoChapters = () => {
|
||||
// Sort by start time to find chronological position
|
||||
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
// Find the index of our new segment
|
||||
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime);
|
||||
const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
|
||||
return `Chapter ${chapterIndex + 1}`;
|
||||
};
|
||||
|
||||
@@ -30,10 +30,16 @@ const useVideoChapters = () => {
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Renumber each segment based on its chronological position
|
||||
return sortedSegments.map((segment, index) => ({
|
||||
// Only update titles that follow the default "Chapter X" pattern to preserve custom titles
|
||||
return sortedSegments.map((segment, index) => {
|
||||
const currentTitle = segment.chapterTitle || '';
|
||||
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
|
||||
|
||||
return {
|
||||
...segment,
|
||||
chapterTitle: `Chapter ${index + 1}`
|
||||
}));
|
||||
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to parse time string (HH:MM:SS.mmm) to seconds
|
||||
@@ -124,9 +130,7 @@ const useVideoChapters = () => {
|
||||
let initialSegments: Segment[] = [];
|
||||
|
||||
// Check if we have existing chapters from the backend
|
||||
const existingChapters =
|
||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
|
||||
[];
|
||||
const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
|
||||
|
||||
if (existingChapters.length > 0) {
|
||||
// Create segments from existing chapters
|
||||
@@ -150,7 +154,7 @@ const useVideoChapters = () => {
|
||||
// Create a default segment that spans the entire video on first load
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
chapterTitle: '',
|
||||
chapterTitle: 'Chapter 1',
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
};
|
||||
@@ -564,8 +568,11 @@ const useVideoChapters = () => {
|
||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
|
||||
);
|
||||
|
||||
// Renumber all segments to ensure proper chronological naming
|
||||
const renumberedSegments = renumberAllSegments(e.detail.segments);
|
||||
|
||||
// Update segment state immediately for UI feedback
|
||||
setClipSegments(e.detail.segments);
|
||||
setClipSegments(renumberedSegments);
|
||||
|
||||
// Always save state to history for non-intermediate actions
|
||||
if (isSignificantChange) {
|
||||
@@ -573,7 +580,7 @@ const useVideoChapters = () => {
|
||||
// ensure we capture the state properly
|
||||
setTimeout(() => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
||||
const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
|
||||
|
||||
// Create a complete state snapshot
|
||||
const stateWithAction: EditorState = {
|
||||
|
||||
@@ -8,12 +8,40 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Video wrapper for positioning background */
|
||||
.ios-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.ios-audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ios-video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 16/9;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.ios-video-player-container video.audio-with-poster {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ios-time-display {
|
||||
|
||||
@@ -76,10 +76,26 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
@@ -88,6 +104,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.video-player-container video.audio-with-poster {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@@ -109,6 +130,7 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
@@ -187,6 +209,7 @@
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
|
||||
@@ -253,7 +253,8 @@ const App = () => {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.max(videoRef.current.currentTime - 10, 0);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
@@ -261,7 +262,8 @@ const App = () => {
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
if (videoRef.current) {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
// Use the video element's current time directly to avoid stale state
|
||||
const newTime = Math.min(videoRef.current.currentTime + 10, duration);
|
||||
handleMobileSafeSeek(newTime);
|
||||
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
|
||||
}
|
||||
@@ -274,7 +276,7 @@ const App = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
|
||||
}, [handlePlay, handleMobileSafeSeek, duration, videoRef]);
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
|
||||
@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||
const [isAudioFile, setIsAudioFile] = useState(false);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
setVideoUrl(url);
|
||||
|
||||
// Check if the media is an audio file and set poster image
|
||||
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||
setIsAudioFile(audioFile);
|
||||
|
||||
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
@@ -128,10 +130,21 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Video container with persistent background for audio files */}
|
||||
<div className="ios-video-wrapper">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="ios-audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
@@ -144,6 +157,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
|
||||
@@ -779,12 +779,13 @@ const TimelineControls = ({
|
||||
|
||||
// Update display time and check for transitions between segments and empty spaces
|
||||
useEffect(() => {
|
||||
// Always update display time to match current video time when playing
|
||||
// Always update display time to match current video time
|
||||
if (videoRef.current) {
|
||||
// If video is playing, always update the displayed time in the tooltip
|
||||
if (!videoRef.current.paused) {
|
||||
// Always update display time when current time changes (both playing and paused)
|
||||
setDisplayTime(currentTime);
|
||||
|
||||
// If video is playing, also update the tooltip and perform segment checks
|
||||
if (!videoRef.current.paused) {
|
||||
// Also update clicked time to keep them in sync when playing
|
||||
// This ensures correct time is shown when pausing
|
||||
setClickedTime(currentTime);
|
||||
|
||||
@@ -47,15 +47,25 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
|
||||
|
||||
// Detect iOS device
|
||||
// Detect iOS device and Safari browser
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
const checkSafari = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
|
||||
};
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
// Store Safari detection globally for other components
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).isSafari = checkSafari();
|
||||
}
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== 'undefined') {
|
||||
const wasInitialized = localStorage.getItem('video_initialized') === 'true';
|
||||
@@ -343,9 +353,19 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
{/* Persistent background image for audio files (Safari fix) */}
|
||||
{isAudioFile && posterImage && (
|
||||
<div
|
||||
className="audio-poster-background"
|
||||
style={{ backgroundImage: `url(${posterImage})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
@@ -356,7 +376,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
poster={posterImage}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
{/* Safari fallback for audio files */}
|
||||
<source src={sampleVideoUrl} type="audio/mp4" />
|
||||
<source src={sampleVideoUrl} type="audio/mpeg" />
|
||||
<p>Your browser doesn't support HTML5 video or audio.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
|
||||
@@ -8,12 +8,40 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Video wrapper for positioning background */
|
||||
.ios-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.ios-audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ios-video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 16/9;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.ios-video-player-container video.audio-with-poster {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ios-time-display {
|
||||
|
||||
@@ -76,10 +76,26 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Persistent background poster for audio files (Safari fix) */
|
||||
.audio-poster-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-player-container video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
/* Force hardware acceleration */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
@@ -88,6 +104,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Make video transparent only for audio files with poster so background shows through */
|
||||
.video-player-container video.audio-with-poster {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* iOS-specific styles */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.video-player-container video {
|
||||
@@ -109,6 +130,7 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
@@ -187,6 +209,7 @@
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.video-player-container:hover .video-controls {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0">
|
||||
<div id="page-embed">
|
||||
<div id="video-js-root-embed-old" class="video-js-root-embed-old"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0">
|
||||
<div id="video-js-root-main-old" class="video-js-root-main-old"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ class CustomChaptersOverlay extends Component {
|
||||
this.touchStartTime = 0;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
this.scrollY = 0; // Track scroll position before locking
|
||||
|
||||
// Bind methods
|
||||
this.createOverlay = this.createOverlay.bind(this);
|
||||
@@ -31,6 +32,8 @@ class CustomChaptersOverlay extends Component {
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.lockBodyScroll = this.lockBodyScroll.bind(this);
|
||||
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
@@ -65,6 +68,9 @@ class CustomChaptersOverlay extends Component {
|
||||
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
|
||||
setupResizeListener() {
|
||||
@@ -164,6 +170,8 @@ class CustomChaptersOverlay extends Component {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
};
|
||||
chapterClose.appendChild(closeBtn);
|
||||
playlistTitle.appendChild(chapterClose);
|
||||
@@ -355,6 +363,37 @@ class CustomChaptersOverlay extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
lockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Save current scroll position
|
||||
this.scrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
// Lock body scroll with proper iOS handling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${this.scrollY}px`;
|
||||
document.body.style.left = '0';
|
||||
document.body.style.right = '0';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
unlockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Restore body scroll
|
||||
const scrollY = this.scrollY;
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.style.left = '';
|
||||
document.body.style.right = '';
|
||||
document.body.style.width = '';
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
|
||||
toggleOverlay() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
@@ -369,17 +408,11 @@ class CustomChaptersOverlay extends Component {
|
||||
navigator.vibrate(30);
|
||||
}
|
||||
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
if (this.isMobile) {
|
||||
// Lock/unlock body scroll on mobile when overlay opens/closes
|
||||
if (isHidden) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
this.lockBodyScroll();
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -390,7 +423,9 @@ class CustomChaptersOverlay extends Component {
|
||||
m.classList.remove('vjs-lock-showing');
|
||||
m.style.display = 'none';
|
||||
});
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore errors when closing menus
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentChapter() {
|
||||
@@ -406,7 +441,6 @@ class CustomChaptersOverlay extends Component {
|
||||
currentTime >= chapter.startTime &&
|
||||
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
|
||||
|
||||
const handle = item.querySelector('.playlist-drag-handle');
|
||||
const dynamic = item.querySelector('.meta-dynamic');
|
||||
if (isPlaying) {
|
||||
currentChapterIndex = index;
|
||||
@@ -463,11 +497,7 @@ class CustomChaptersOverlay extends Component {
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,11 +509,7 @@ class CustomChaptersOverlay extends Component {
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
|
||||
// Clean up event listeners
|
||||
if (this.handleResize) {
|
||||
|
||||
@@ -25,6 +25,7 @@ class CustomSettingsMenu extends Component {
|
||||
this.isMobile = this.detectMobile();
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.scrollY = 0; // Track scroll position before locking
|
||||
|
||||
// Bind methods
|
||||
this.createSettingsButton = this.createSettingsButton.bind(this);
|
||||
@@ -41,6 +42,8 @@ class CustomSettingsMenu extends Component {
|
||||
this.detectMobile = this.detectMobile.bind(this);
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.lockBodyScroll = this.lockBodyScroll.bind(this);
|
||||
this.unlockBodyScroll = this.unlockBodyScroll.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
@@ -656,6 +659,8 @@ class CustomSettingsMenu extends Component {
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('settings-clicked');
|
||||
}
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', closeFunction);
|
||||
@@ -942,6 +947,37 @@ class CustomSettingsMenu extends Component {
|
||||
this.startSubtitleSync();
|
||||
}
|
||||
|
||||
lockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Save current scroll position
|
||||
this.scrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
// Lock body scroll with proper iOS handling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.top = `-${this.scrollY}px`;
|
||||
document.body.style.left = '0';
|
||||
document.body.style.right = '0';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
unlockBodyScroll() {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
// Restore body scroll
|
||||
const scrollY = this.scrollY;
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.top = '';
|
||||
document.body.style.left = '';
|
||||
document.body.style.right = '';
|
||||
document.body.style.width = '';
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, scrollY);
|
||||
}
|
||||
|
||||
toggleSettings(e) {
|
||||
// e.stopPropagation();
|
||||
const isVisible = this.settingsOverlay.classList.contains('show');
|
||||
@@ -954,11 +990,7 @@ class CustomSettingsMenu extends Component {
|
||||
this.stopKeepingControlsVisible();
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
} else {
|
||||
this.settingsOverlay.classList.add('show');
|
||||
this.settingsOverlay.style.display = 'block';
|
||||
@@ -972,11 +1004,7 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
this.lockBodyScroll();
|
||||
}
|
||||
|
||||
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles
|
||||
@@ -1002,6 +1030,9 @@ class CustomSettingsMenu extends Component {
|
||||
this.settingsOverlay.classList.add('show');
|
||||
this.settingsOverlay.style.display = 'block';
|
||||
|
||||
// Lock body scroll when opening
|
||||
this.lockBodyScroll();
|
||||
|
||||
// Hide other submenus and show subtitles submenu
|
||||
this.speedSubmenu.style.display = 'none';
|
||||
if (this.qualitySubmenu) this.qualitySubmenu.style.display = 'none';
|
||||
@@ -1072,11 +1103,7 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Restore body scroll on mobile when closing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1444,8 @@ class CustomSettingsMenu extends Component {
|
||||
if (btnEl) {
|
||||
btnEl.classList.remove('settings-clicked');
|
||||
}
|
||||
// Restore body scroll on mobile when closing
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1493,11 +1522,7 @@ class CustomSettingsMenu extends Component {
|
||||
}
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
this.unlockBodyScroll();
|
||||
|
||||
// Remove DOM elements
|
||||
if (this.settingsOverlay) {
|
||||
|
||||
@@ -45,17 +45,14 @@ class EndScreenOverlay extends Component {
|
||||
}
|
||||
|
||||
createGrid() {
|
||||
const { columns, maxVideos, useSwiper } = this.getGridConfig();
|
||||
const { columns, maxVideos, useSwiper, itemsPerView, gridRows } = this.getGridConfig();
|
||||
|
||||
// Get videos to show - access directly from options during createEl
|
||||
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
|
||||
const videosToShow =
|
||||
relatedVideos.length > 0
|
||||
? relatedVideos.slice(0, maxVideos)
|
||||
: this.createSampleVideos().slice(0, maxVideos);
|
||||
const videosToShow = relatedVideos.slice(0, maxVideos);
|
||||
|
||||
if (useSwiper) {
|
||||
return this.createSwiperGrid(videosToShow);
|
||||
return this.createSwiperGrid(videosToShow, itemsPerView || 2, columns, gridRows || 1);
|
||||
} else {
|
||||
return this.createRegularGrid(columns, videosToShow);
|
||||
}
|
||||
@@ -91,14 +88,14 @@ class EndScreenOverlay extends Component {
|
||||
return grid;
|
||||
}
|
||||
|
||||
createSwiperGrid(videosToShow) {
|
||||
createSwiperGrid(videosToShow, itemsPerView = 2, columns = 2, gridRows = 1) {
|
||||
const container = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-videos-swiper-container',
|
||||
});
|
||||
|
||||
// Container styling - ensure it stays within bounds
|
||||
container.style.position = 'relative';
|
||||
container.style.padding = '20px';
|
||||
container.style.padding = gridRows > 1 ? '12px' : '20px'; // Minimal padding for 2x2 grid
|
||||
container.style.height = '100%';
|
||||
container.style.width = '100%';
|
||||
container.style.display = 'flex';
|
||||
@@ -111,6 +108,21 @@ class EndScreenOverlay extends Component {
|
||||
className: 'vjs-related-videos-swiper',
|
||||
});
|
||||
|
||||
if (gridRows > 1) {
|
||||
// Multi-row grid layout (e.g., 2x2 for landscape)
|
||||
swiperWrapper.style.display = 'flex';
|
||||
swiperWrapper.style.overflowX = 'auto';
|
||||
swiperWrapper.style.overflowY = 'hidden';
|
||||
swiperWrapper.style.scrollBehavior = 'smooth';
|
||||
swiperWrapper.style.scrollSnapType = 'x mandatory';
|
||||
swiperWrapper.style.width = '100%';
|
||||
swiperWrapper.style.maxWidth = '100%';
|
||||
swiperWrapper.style.height = '100%';
|
||||
swiperWrapper.style.flex = '1';
|
||||
swiperWrapper.style.boxSizing = 'border-box';
|
||||
swiperWrapper.style.gap = '0'; // Remove gap, we'll handle it in pages
|
||||
} else {
|
||||
// Single row layout (original swiper)
|
||||
swiperWrapper.style.display = 'flex';
|
||||
swiperWrapper.style.overflowX = 'auto';
|
||||
swiperWrapper.style.overflowY = 'hidden';
|
||||
@@ -121,6 +133,7 @@ class EndScreenOverlay extends Component {
|
||||
swiperWrapper.style.width = '100%';
|
||||
swiperWrapper.style.maxWidth = '100%';
|
||||
swiperWrapper.style.boxSizing = 'border-box';
|
||||
}
|
||||
|
||||
// Hide scrollbar and prevent scroll propagation
|
||||
swiperWrapper.style.scrollbarWidth = 'none'; // Firefox
|
||||
@@ -158,17 +171,56 @@ class EndScreenOverlay extends Component {
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Create video items for swiper (show 2 at a time, but allow scrolling through all)
|
||||
if (gridRows > 1) {
|
||||
// Create pages with grid layout (e.g., 2x2 grid per page)
|
||||
const itemsPerPage = itemsPerView;
|
||||
const totalPages = Math.ceil(videosToShow.length / itemsPerPage);
|
||||
|
||||
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
const page = videojs.dom.createEl('div', {
|
||||
className: 'vjs-swiper-page',
|
||||
});
|
||||
|
||||
page.style.minWidth = '100%';
|
||||
page.style.width = '100%';
|
||||
page.style.height = '100%';
|
||||
page.style.display = 'grid';
|
||||
page.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||||
page.style.gridTemplateRows = `repeat(${gridRows}, 1fr)`;
|
||||
page.style.gap = '12px'; // Increased gap for better spacing
|
||||
page.style.scrollSnapAlign = 'start';
|
||||
page.style.boxSizing = 'border-box';
|
||||
page.style.alignContent = 'stretch';
|
||||
page.style.justifyContent = 'stretch';
|
||||
page.style.alignItems = 'stretch';
|
||||
page.style.justifyItems = 'stretch';
|
||||
|
||||
// Get videos for this page
|
||||
const startIndex = pageIndex * itemsPerPage;
|
||||
const endIndex = Math.min(startIndex + itemsPerPage, videosToShow.length);
|
||||
const pageVideos = videosToShow.slice(startIndex, endIndex);
|
||||
|
||||
// Create video items for this page
|
||||
pageVideos.forEach((video) => {
|
||||
const videoItem = this.createVideoItem(video, true, itemsPerView, true); // Pass true for grid mode
|
||||
page.appendChild(videoItem);
|
||||
});
|
||||
|
||||
swiperWrapper.appendChild(page);
|
||||
}
|
||||
} else {
|
||||
// Single row - create video items directly
|
||||
videosToShow.forEach((video) => {
|
||||
const videoItem = this.createVideoItem(video, true); // Pass true for swiper mode
|
||||
const videoItem = this.createVideoItem(video, true, itemsPerView, false);
|
||||
swiperWrapper.appendChild(videoItem);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(swiperWrapper);
|
||||
|
||||
// Add navigation indicators if there are more than 2 videos
|
||||
if (videosToShow.length > 2) {
|
||||
const indicators = this.createSwiperIndicators(videosToShow.length, swiperWrapper);
|
||||
// Add navigation indicators if there are more videos than can fit in one view
|
||||
if (videosToShow.length > itemsPerView) {
|
||||
const indicators = this.createSwiperIndicators(videosToShow.length, swiperWrapper, itemsPerView);
|
||||
container.appendChild(indicators);
|
||||
}
|
||||
|
||||
@@ -190,10 +242,48 @@ class EndScreenOverlay extends Component {
|
||||
// Calculate maximum rows that can fit - be more aggressive
|
||||
const maxRows = Math.max(2, Math.floor((availableHeight + gap) / (cardHeight + gap)));
|
||||
|
||||
console.log('Grid Config:', { playerWidth, playerHeight, availableHeight, maxRows });
|
||||
// Detect landscape orientation on mobile
|
||||
// Check screen/window orientation first, then player dimensions
|
||||
const screenWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const screenHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const isScreenLandscape = screenWidth > screenHeight;
|
||||
|
||||
const isLandscape = playerWidth > playerHeight;
|
||||
|
||||
// Detect mobile/touch devices - should always show swiper
|
||||
// Check both width and touch capability for better detection
|
||||
const isTouchDevice = this.isTouchDevice;
|
||||
const isSmallScreen = screenWidth < 700 || playerWidth < 700;
|
||||
const isMobileOrTouch = isTouchDevice || isSmallScreen;
|
||||
|
||||
// For mobile, prioritize screen orientation over player dimensions
|
||||
// Only consider it landscape if BOTH screen and player are in landscape
|
||||
const isDefinitelyLandscape = isMobileOrTouch ? isScreenLandscape && isLandscape : isLandscape;
|
||||
|
||||
console.log('Grid Config:', {
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
isScreenLandscape,
|
||||
playerWidth,
|
||||
playerHeight,
|
||||
availableHeight,
|
||||
maxRows,
|
||||
isLandscape,
|
||||
isDefinitelyLandscape,
|
||||
isTouchDevice,
|
||||
isSmallScreen,
|
||||
isMobileOrTouch,
|
||||
});
|
||||
|
||||
// Enhanced grid configuration to fill all available space
|
||||
if (playerWidth >= 1600) {
|
||||
// Check mobile/touch conditions first - swiper should ALWAYS be used on mobile/touch devices
|
||||
if (isMobileOrTouch && isDefinitelyLandscape) {
|
||||
// Mobile/Touch landscape: show 2x2 grid (4 items total) with swiper for pagination
|
||||
return { columns: 2, maxVideos: 12, useSwiper: true, itemsPerView: 4, gridRows: 2 };
|
||||
} else if (isMobileOrTouch) {
|
||||
// Mobile/Touch portrait: show 2 items in single row swiper mode
|
||||
return { columns: 2, maxVideos: 12, useSwiper: true, itemsPerView: 2, gridRows: 1 };
|
||||
} else if (playerWidth >= 1600) {
|
||||
const columns = 5;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else if (playerWidth >= 1200) {
|
||||
@@ -202,11 +292,9 @@ class EndScreenOverlay extends Component {
|
||||
} else if (playerWidth >= 900) {
|
||||
const columns = 3;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else if (playerWidth >= 700) {
|
||||
} else {
|
||||
const columns = 2;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else {
|
||||
return { columns: 2, maxVideos: 12, useSwiper: true }; // Use swiper for small screens
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +304,11 @@ class EndScreenOverlay extends Component {
|
||||
if (this.relatedVideos && Array.isArray(this.relatedVideos) && this.relatedVideos.length > 0) {
|
||||
return this.relatedVideos.slice(0, maxVideos);
|
||||
}
|
||||
// Fallback to sample videos for testing
|
||||
return this.createSampleVideos().slice(0, maxVideos);
|
||||
// Return empty array if no related videos
|
||||
return [];
|
||||
}
|
||||
|
||||
createVideoItem(video, isSwiperMode = false) {
|
||||
createVideoItem(video, isSwiperMode = false, itemsPerView = 2, isGridMode = false) {
|
||||
const item = videojs.dom.createEl('div', {
|
||||
className: `vjs-related-video-item ${isSwiperMode ? 'vjs-swiper-item' : ''}`,
|
||||
});
|
||||
@@ -228,7 +316,7 @@ class EndScreenOverlay extends Component {
|
||||
// Consistent item styling with fixed dimensions
|
||||
item.style.position = 'relative';
|
||||
item.style.backgroundColor = '#1a1a1a';
|
||||
item.style.borderRadius = '6px';
|
||||
item.style.borderRadius = '8px';
|
||||
item.style.overflow = 'hidden';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.transition = 'transform 0.15s ease, box-shadow 0.15s ease';
|
||||
@@ -236,11 +324,21 @@ class EndScreenOverlay extends Component {
|
||||
item.style.flexDirection = 'column';
|
||||
|
||||
// Consistent dimensions for all cards
|
||||
if (isSwiperMode) {
|
||||
// Calculate proper width for swiper items (2 items visible + gap)
|
||||
item.style.minWidth = 'calc(50% - 6px)'; // 50% width minus half the gap
|
||||
item.style.width = 'calc(50% - 6px)';
|
||||
item.style.maxWidth = '180px'; // Maximum width for larger screens
|
||||
if (isGridMode) {
|
||||
// Grid mode (2x2): items fill their grid cell completely
|
||||
item.style.height = '100%';
|
||||
item.style.minHeight = '0';
|
||||
item.style.width = '100%';
|
||||
item.style.maxWidth = 'none';
|
||||
item.style.flex = '1';
|
||||
} else if (isSwiperMode) {
|
||||
// Single row swiper mode: calculate width based on items per view
|
||||
// Formula: (100% / itemsPerView) - (gap * (itemsPerView - 1) / itemsPerView)
|
||||
const itemsPerRow = itemsPerView / (itemsPerView === 4 ? 2 : 1); // For 4 items in 2 rows, show 2 per row
|
||||
const gapAdjustment = (12 * (itemsPerRow - 1)) / itemsPerRow;
|
||||
item.style.minWidth = `calc(${100 / itemsPerRow}% - ${gapAdjustment}px)`;
|
||||
item.style.width = `calc(${100 / itemsPerRow}% - ${gapAdjustment}px)`;
|
||||
item.style.maxWidth = itemsPerView === 4 ? '150px' : '180px'; // Smaller max width for 4 items
|
||||
|
||||
// Simpler height since text is overlaid on thumbnail
|
||||
const cardHeight = '120px'; // Just the thumbnail height
|
||||
@@ -270,7 +368,7 @@ class EndScreenOverlay extends Component {
|
||||
}
|
||||
|
||||
// Create thumbnail container with overlaid text
|
||||
const thumbnailContainer = this.createThumbnailWithOverlay(video, isSwiperMode);
|
||||
const thumbnailContainer = this.createThumbnailWithOverlay(video, isSwiperMode, itemsPerView);
|
||||
item.appendChild(thumbnailContainer);
|
||||
|
||||
console.log('Created video item with overlay:', item);
|
||||
@@ -331,7 +429,7 @@ class EndScreenOverlay extends Component {
|
||||
return container;
|
||||
}
|
||||
|
||||
createThumbnailWithOverlay(video, isSwiperMode = false) {
|
||||
createThumbnailWithOverlay(video, isSwiperMode = false, itemsPerView = 2) {
|
||||
const container = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-thumbnail-container',
|
||||
});
|
||||
@@ -339,9 +437,13 @@ class EndScreenOverlay extends Component {
|
||||
// Container styling - full height since it contains everything
|
||||
container.style.position = 'relative';
|
||||
container.style.width = '100%';
|
||||
container.style.height = '120px';
|
||||
container.style.height = '100%';
|
||||
container.style.minHeight = '120px';
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.borderRadius = '6px';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.flex = '1';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
|
||||
// Create thumbnail image
|
||||
const thumbnail = videojs.dom.createEl('img', {
|
||||
@@ -354,6 +456,9 @@ class EndScreenOverlay extends Component {
|
||||
thumbnail.style.height = '100%';
|
||||
thumbnail.style.objectFit = 'cover';
|
||||
thumbnail.style.display = 'block';
|
||||
thumbnail.style.flex = '1';
|
||||
thumbnail.style.minWidth = '0';
|
||||
thumbnail.style.minHeight = '0';
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
|
||||
@@ -370,7 +475,7 @@ class EndScreenOverlay extends Component {
|
||||
duration.style.color = 'white';
|
||||
duration.style.padding = '2px 6px';
|
||||
duration.style.borderRadius = '3px';
|
||||
duration.style.fontSize = '11px';
|
||||
duration.style.fontSize = itemsPerView === 4 ? '10px' : '11px';
|
||||
duration.style.fontWeight = '600';
|
||||
duration.style.lineHeight = '1';
|
||||
duration.style.zIndex = '3';
|
||||
@@ -384,12 +489,12 @@ class EndScreenOverlay extends Component {
|
||||
});
|
||||
|
||||
textOverlay.style.position = 'absolute';
|
||||
textOverlay.style.top = '8px';
|
||||
textOverlay.style.left = '8px';
|
||||
textOverlay.style.right = '8px';
|
||||
textOverlay.style.top = itemsPerView === 4 ? '6px' : '8px';
|
||||
textOverlay.style.left = itemsPerView === 4 ? '6px' : '8px';
|
||||
textOverlay.style.right = itemsPerView === 4 ? '6px' : '8px';
|
||||
textOverlay.style.background =
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 70%, transparent 100%)';
|
||||
textOverlay.style.padding = '8px';
|
||||
textOverlay.style.padding = itemsPerView === 4 ? '6px' : '8px';
|
||||
textOverlay.style.borderRadius = '4px';
|
||||
textOverlay.style.zIndex = '2';
|
||||
|
||||
@@ -399,10 +504,11 @@ class EndScreenOverlay extends Component {
|
||||
});
|
||||
title.textContent = video.title || 'Sample Video Title';
|
||||
title.style.color = '#ffffff';
|
||||
title.style.fontSize = isSwiperMode ? '12px' : '13px';
|
||||
// Adjust font sizes based on items per view
|
||||
title.style.fontSize = itemsPerView === 4 ? '11px' : isSwiperMode ? '12px' : '13px';
|
||||
title.style.fontWeight = '600';
|
||||
title.style.lineHeight = '1.3';
|
||||
title.style.marginBottom = '4px';
|
||||
title.style.marginBottom = itemsPerView === 4 ? '3px' : '4px';
|
||||
title.style.overflow = 'hidden';
|
||||
title.style.textOverflow = 'ellipsis';
|
||||
title.style.display = '-webkit-box';
|
||||
@@ -428,7 +534,8 @@ class EndScreenOverlay extends Component {
|
||||
|
||||
meta.textContent = metaText;
|
||||
meta.style.color = '#e0e0e0';
|
||||
meta.style.fontSize = isSwiperMode ? '10px' : '11px';
|
||||
// Adjust font sizes based on items per view
|
||||
meta.style.fontSize = itemsPerView === 4 ? '9px' : isSwiperMode ? '10px' : '11px';
|
||||
meta.style.lineHeight = '1.2';
|
||||
meta.style.overflow = 'hidden';
|
||||
meta.style.textOverflow = 'ellipsis';
|
||||
@@ -529,7 +636,7 @@ class EndScreenOverlay extends Component {
|
||||
return info;
|
||||
}
|
||||
|
||||
createSwiperIndicators(totalVideos, swiperWrapper) {
|
||||
createSwiperIndicators(totalVideos, swiperWrapper, itemsPerView = 2) {
|
||||
const indicators = videojs.dom.createEl('div', {
|
||||
className: 'vjs-swiper-indicators',
|
||||
});
|
||||
@@ -539,7 +646,6 @@ class EndScreenOverlay extends Component {
|
||||
indicators.style.gap = '8px';
|
||||
indicators.style.marginTop = '10px';
|
||||
|
||||
const itemsPerView = 2;
|
||||
const totalPages = Math.ceil(totalVideos / itemsPerView);
|
||||
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
@@ -636,154 +742,13 @@ class EndScreenOverlay extends Component {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
createSampleVideos() {
|
||||
return [
|
||||
{
|
||||
id: 'sample1',
|
||||
title: 'React Full Course - Complete Tutorial for Beginners',
|
||||
author: 'Bro Code',
|
||||
views: '2.1M views',
|
||||
duration: 1800,
|
||||
},
|
||||
{
|
||||
id: 'sample2',
|
||||
title: 'JavaScript ES6+ Modern Features',
|
||||
author: 'Tech Tutorials',
|
||||
views: '850K views',
|
||||
duration: 1200,
|
||||
},
|
||||
{
|
||||
id: 'sample3',
|
||||
title: 'CSS Grid Layout Masterclass',
|
||||
author: 'Web Dev Academy',
|
||||
views: '1.2M views',
|
||||
duration: 2400,
|
||||
},
|
||||
{
|
||||
id: 'sample4',
|
||||
title: 'Node.js Backend Development',
|
||||
author: 'Code Master',
|
||||
views: '650K views',
|
||||
duration: 3600,
|
||||
},
|
||||
{
|
||||
id: 'sample5',
|
||||
title: 'Vue.js Complete Guide',
|
||||
author: 'Frontend Pro',
|
||||
views: '980K views',
|
||||
duration: 2800,
|
||||
},
|
||||
{
|
||||
id: 'sample6',
|
||||
title: 'Python Data Science Bootcamp',
|
||||
author: 'Data Academy',
|
||||
views: '1.5M views',
|
||||
duration: 4200,
|
||||
},
|
||||
{
|
||||
id: 'sample7',
|
||||
title: 'TypeScript for Beginners',
|
||||
author: 'Code School',
|
||||
views: '750K views',
|
||||
duration: 1950,
|
||||
},
|
||||
{
|
||||
id: 'sample8',
|
||||
title: 'Docker Container Tutorial',
|
||||
author: 'DevOps Pro',
|
||||
views: '920K views',
|
||||
duration: 2700,
|
||||
},
|
||||
{
|
||||
id: 'sample9',
|
||||
title: 'MongoDB Database Design',
|
||||
author: 'DB Expert',
|
||||
views: '580K views',
|
||||
duration: 3200,
|
||||
},
|
||||
{
|
||||
id: 'sample10',
|
||||
title: 'AWS Cloud Computing Essentials',
|
||||
author: 'Cloud Master',
|
||||
views: '1.8M views',
|
||||
duration: 4800,
|
||||
},
|
||||
{
|
||||
id: 'sample11',
|
||||
title: 'GraphQL API Development',
|
||||
author: 'API Guru',
|
||||
views: '420K views',
|
||||
duration: 2100,
|
||||
},
|
||||
{
|
||||
id: 'sample12',
|
||||
title: 'Kubernetes Orchestration Guide',
|
||||
author: 'Container Pro',
|
||||
views: '680K views',
|
||||
duration: 3900,
|
||||
},
|
||||
{
|
||||
id: 'sample13',
|
||||
title: 'Redis Caching Strategies',
|
||||
author: 'Cache Expert',
|
||||
views: '520K views',
|
||||
duration: 2250,
|
||||
},
|
||||
{
|
||||
id: 'sample14',
|
||||
title: 'Web Performance Optimization',
|
||||
author: 'Speed Master',
|
||||
views: '890K views',
|
||||
duration: 3100,
|
||||
},
|
||||
{
|
||||
id: 'sample15',
|
||||
title: 'CI/CD Pipeline Setup',
|
||||
author: 'DevOps Guide',
|
||||
views: '710K views',
|
||||
duration: 2900,
|
||||
},
|
||||
{
|
||||
id: 'sample16',
|
||||
title: 'Microservices Architecture',
|
||||
author: 'System Design',
|
||||
views: '1.3M views',
|
||||
duration: 4500,
|
||||
},
|
||||
{
|
||||
id: 'sample17',
|
||||
title: 'Next.js App Router Tutorial',
|
||||
author: 'Web Academy',
|
||||
views: '640K views',
|
||||
duration: 2650,
|
||||
},
|
||||
{
|
||||
id: 'sample18',
|
||||
title: 'Tailwind CSS Crash Course',
|
||||
author: 'CSS Master',
|
||||
views: '1.1M views',
|
||||
duration: 1800,
|
||||
},
|
||||
{
|
||||
id: 'sample19',
|
||||
title: 'Git and GitHub Essentials',
|
||||
author: 'Version Control Pro',
|
||||
views: '2.3M views',
|
||||
duration: 3300,
|
||||
},
|
||||
{
|
||||
id: 'sample20',
|
||||
title: 'REST API Best Practices',
|
||||
author: 'API Design',
|
||||
views: '780K views',
|
||||
duration: 2400,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
show() {
|
||||
// Only show if there are related videos
|
||||
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
|
||||
if (relatedVideos.length > 0) {
|
||||
this.el().style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.el().style.display = 'none';
|
||||
|
||||
@@ -139,12 +139,6 @@ video::cue {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.video-js .vjs-control-bar .vjs-autoplay-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.video-js .vjs-control-bar .vjs-next-video-button {
|
||||
display: none !important;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
||||
/* ===== EMBED PLAYER STYLES ===== */
|
||||
/* Styles specific to #page-embed and embedded video players */
|
||||
|
||||
/* Fullscreen video styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js video {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js .vjs-poster {
|
||||
border-radius: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
/* Fullscreen styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Fullscreen fluid styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-fluid {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Fullscreen video-js player styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent scrollbars in embed video player */
|
||||
}
|
||||
|
||||
/* Prevent page scrolling when embed is active */
|
||||
#page-embed .video-js-root-embed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
overflow: hidden; /* Prevent scrollbars in embed mode */
|
||||
}
|
||||
|
||||
/* Sticky controls for embed player - always at bottom of window */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
z-index: 1001 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
padding: 0 12px !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure progress bar is also sticky for embed player */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-progress-control {
|
||||
position: fixed !important;
|
||||
bottom: 48px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
z-index: 1000 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Ensure gradient overlay extends to full window width for embed */
|
||||
#page-embed .video-js-root-embed .video-js::after {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 120px !important;
|
||||
z-index: 999 !important;
|
||||
}
|
||||
|
||||
/* Mobile optimizations for embed player sticky controls */
|
||||
@media (max-width: 768px) {
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
height: 56px !important; /* Larger touch target on mobile */
|
||||
padding: 0 16px !important; /* More padding for touch */
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js .vjs-progress-control {
|
||||
bottom: 44px !important; /* Much closer to control bar - minimal gap */
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Ensure controls don't interfere with mobile browser chrome */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure controls are always visible when user is active (embed only) */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-active .vjs-control-bar,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-paused .vjs-control-bar,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
/* Smooth transitions for control visibility */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease !important;
|
||||
}
|
||||
|
||||
/* Hide controls when user is inactive (but keep them sticky) */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-inactive:not(.vjs-paused):not(.vjs-ended) .vjs-control-bar {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(100%) !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-inactive:not(.vjs-paused):not(.vjs-ended) .vjs-progress-control {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC SEEK INDICATOR POSITIONING ===== */
|
||||
/* Ensure play icon (SeekIndicator) stays centered in embed view regardless of window size */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-seek-indicator {
|
||||
position: fixed !important;
|
||||
top: 50vh !important;
|
||||
left: 50vw !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 10000 !important;
|
||||
pointer-events: none !important;
|
||||
display: none !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
transition: opacity 0.2s ease-in-out !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC BIG PLAY BUTTON POSITIONING ===== */
|
||||
/* Ensure big play button stays centered in embed view regardless of window size */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-big-play-button {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 1000 !important;
|
||||
pointer-events: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC CONTROLS HIDING FOR INITIAL STATE ===== */
|
||||
/* Hide seekbar and controls when poster is displayed (before first play) in embed mode */
|
||||
#page-embed .video-js-root-embed .video-js:not(.vjs-has-started) .vjs-control-bar {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js:not(.vjs-has-started) .vjs-progress-control {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { translateString } from '../utils/helpers/';
|
||||
interface User {
|
||||
name: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface BulkActionChangeOwnerModalProps {
|
||||
@@ -76,7 +77,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
|
||||
|
||||
const handleUserSelect = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setSearchTerm(user.name + ' - ' + user.username);
|
||||
setSearchTerm(user.name + ' - ' + (user.email || user.username));
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
@@ -147,7 +148,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
|
||||
className="search-result-item"
|
||||
onClick={() => handleUserSelect(user)}
|
||||
>
|
||||
{user.name} - {user.username}
|
||||
{user.name} - {user.email || user.username}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,7 +157,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
|
||||
|
||||
{selectedUser && (
|
||||
<div className="selected-user">
|
||||
<span>{translateString('Selected')}: {selectedUser.name} - {selectedUser.username}</span>
|
||||
<span>{translateString('Selected')}: {selectedUser.name} - {selectedUser.email || selectedUser.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { translateString } from '../utils/helpers/';
|
||||
interface User {
|
||||
name: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface BulkActionPermissionModalProps {
|
||||
@@ -28,7 +29,7 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
}) => {
|
||||
const [existingUsers, setExistingUsers] = useState<string[]>([]);
|
||||
const [existingSearchTerm, setExistingSearchTerm] = useState('');
|
||||
const [usersToAdd, setUsersToAdd] = useState<string[]>([]);
|
||||
const [usersToAdd, setUsersToAdd] = useState<Array<{ username: string; display: string }>>([]);
|
||||
const [usersToRemove, setUsersToRemove] = useState<string[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<User[]>([]);
|
||||
const [addSearchTerm, setAddSearchTerm] = useState('');
|
||||
@@ -124,17 +125,17 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
setSearchTimeout(timeout);
|
||||
};
|
||||
|
||||
const addUserToList = (username: string, name: string) => {
|
||||
const userDisplay = `${name} - ${username}`;
|
||||
if (!usersToAdd.includes(userDisplay) && !existingUsers.includes(userDisplay)) {
|
||||
setUsersToAdd([...usersToAdd, userDisplay]);
|
||||
const addUserToList = (username: string, name: string, email?: string) => {
|
||||
const userDisplay = `${name} - ${email || username}`;
|
||||
if (!usersToAdd.some(u => u.username === username) && !existingUsers.includes(userDisplay)) {
|
||||
setUsersToAdd([...usersToAdd, { username, display: userDisplay }]);
|
||||
setAddSearchTerm('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeUserFromAddList = (user: string) => {
|
||||
setUsersToAdd(usersToAdd.filter((u) => u !== user));
|
||||
const removeUserFromAddList = (username: string) => {
|
||||
setUsersToAdd(usersToAdd.filter((u) => u.username !== username));
|
||||
};
|
||||
|
||||
const markUserForRemoval = (user: string) => {
|
||||
@@ -148,7 +149,8 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
};
|
||||
|
||||
const extractUsername = (userDisplay: string): string => {
|
||||
// Extract username from "Name - username" format
|
||||
// For existing users from API, extract username from "Name - username/email" format
|
||||
// Note: This assumes the username is after the last ' - ' separator
|
||||
const parts = userDisplay.split(' - ');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : userDisplay;
|
||||
};
|
||||
@@ -159,7 +161,7 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
try {
|
||||
// First, add users if any
|
||||
if (usersToAdd.length > 0) {
|
||||
const usernamesToAdd = usersToAdd.map(extractUsername);
|
||||
const usernamesToAdd = usersToAdd.map(u => u.username);
|
||||
const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -249,9 +251,9 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
<div
|
||||
key={user.username}
|
||||
className="search-result-item"
|
||||
onClick={() => addUserToList(user.username, user.name)}
|
||||
onClick={() => addUserToList(user.username, user.name, user.email)}
|
||||
>
|
||||
{user.name} - {user.username}
|
||||
{user.name} - {user.email || user.username}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -263,9 +265,9 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
<div className="empty-message">{translateString('No users to add')}</div>
|
||||
) : (
|
||||
usersToAdd.map((user) => (
|
||||
<div key={user} className="user-item">
|
||||
<span>{user}</span>
|
||||
<button className="remove-btn" onClick={() => removeUserFromAddList(user)}>
|
||||
<div key={user.username} className="user-item">
|
||||
<span>{user.display}</span>
|
||||
<button className="remove-btn" onClick={() => removeUserFromAddList(user.username)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -276,7 +278,7 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
|
||||
|
||||
<div className="permission-panel">
|
||||
<h3>
|
||||
{translateString('To add')}
|
||||
{permissionType === 'viewer' ? translateString('Existing co-viewers') : permissionType === 'editor' ? translateString('Existing co-editors') : translateString('Existing co-owners')}
|
||||
{selectedMediaIds.length > 1 && (
|
||||
<span className="info-tooltip" title={translateString('The intersection of users in the selected media is shown')}>
|
||||
?
|
||||
|
||||
@@ -26,17 +26,12 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
csrfToken,
|
||||
}) => {
|
||||
const [selectedState, setSelectedState] = useState('public');
|
||||
const [initialState, setInitialState] = useState('public');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setSelectedState('public');
|
||||
setInitialState('public');
|
||||
} else {
|
||||
// When modal opens, set initial state
|
||||
setInitialState('public');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -79,7 +74,9 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasStateChanged = selectedState !== initialState;
|
||||
// Note: We don't check hasStateChanged because the modal doesn't know the actual
|
||||
// current state of the selected media. Users should be able to set any state.
|
||||
// If the state is already the same, the backend will handle it gracefully.
|
||||
|
||||
return (
|
||||
<div className="publish-state-modal-overlay">
|
||||
@@ -116,7 +113,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
|
||||
<button
|
||||
className="publish-state-btn publish-state-btn-submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={isProcessing || !hasStateChanged}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? translateString('Processing...') : translateString('Submit')}
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
const VideoJSEmbed = ({
|
||||
data,
|
||||
useRoundedCorners,
|
||||
version,
|
||||
isPlayList,
|
||||
playerVolume,
|
||||
playerSoundMuted,
|
||||
@@ -67,6 +68,7 @@ const VideoJSEmbed = ({
|
||||
window.MEDIA_DATA = {
|
||||
data: data || {},
|
||||
useRoundedCorners: useRoundedCorners,
|
||||
version: version,
|
||||
isPlayList: isPlayList,
|
||||
playerVolume: playerVolume || 0.5,
|
||||
playerSoundMuted: playerSoundMuted || (urlMuted === '1'),
|
||||
@@ -204,14 +206,14 @@ const VideoJSEmbed = ({
|
||||
if (!existingCSS) {
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = siteUrl + '/static/video_js/video-js.css';
|
||||
cssLink.href = siteUrl + '/static/video_js/video-js.css?v=' + version;
|
||||
document.head.appendChild(cssLink);
|
||||
}
|
||||
|
||||
// Load JS if not already loaded
|
||||
if (!existingJS) {
|
||||
const script = document.createElement('script');
|
||||
script.src = siteUrl + '/static/video_js/video-js.js';
|
||||
script.src = siteUrl + '/static/video_js/video-js.js?v=' + version;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +49,11 @@ export function UserItemMemberSince(props) {
|
||||
}
|
||||
|
||||
export function TaxonomyItemMediaCount(props) {
|
||||
// Check if listing numbers should be included based on settings
|
||||
if (!window.MediaCMS.features.listings.includeNumbers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key="item-media-count" className="item-media-count">
|
||||
{' ' + props.count} media
|
||||
|
||||
@@ -21,12 +21,16 @@ function downloadOptionsList() {
|
||||
for (g in encodings_info[k]) {
|
||||
if (encodings_info[k].hasOwnProperty(g)) {
|
||||
if ('success' === encodings_info[k][g].status && 100 === encodings_info[k][g].progress && null !== encodings_info[k][g].url) {
|
||||
// Use original media URL for download instead of encoded version
|
||||
const originalUrl = media_data.original_media_url;
|
||||
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||
|
||||
optionsList[encodings_info[k][g].title] = {
|
||||
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')',
|
||||
link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
|
||||
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||
linkAttr: {
|
||||
target: '_blank',
|
||||
download: media_data.title + '_' + k + '_' + g.toUpperCase(),
|
||||
download: originalFilename,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,12 +40,16 @@ function downloadOptionsList() {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract actual filename from the original media URL
|
||||
const originalUrl = media_data.original_media_url;
|
||||
const originalFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : media_data.title;
|
||||
|
||||
optionsList.original_media_url = {
|
||||
text: 'Original file (' + media_data.size + ')',
|
||||
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
|
||||
linkAttr: {
|
||||
target: '_blank',
|
||||
download: media_data.title,
|
||||
download: originalFilename,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
|
||||
: null;
|
||||
|
||||
// Extract actual filename from URL for non-video downloads
|
||||
const originalUrl = MediaPageStore.get('media-original-url');
|
||||
this.downloadFilename = originalUrl ? originalUrl.substring(originalUrl.lastIndexOf('/') + 1) : this.props.title;
|
||||
|
||||
this.updateStateValues = this.updateStateValues.bind(this);
|
||||
}
|
||||
|
||||
@@ -171,7 +175,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
|
||||
@@ -77,7 +77,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
|
||||
@@ -392,6 +392,7 @@ export default class VideoViewer extends React.PureComponent {
|
||||
return React.createElement(VideoJSEmbed, {
|
||||
data: this.props.data,
|
||||
useRoundedCorners: site.useRoundedCorners,
|
||||
version: site.version,
|
||||
isPlayList: !!MediaPageStore.get('playlist-id'),
|
||||
playerVolume: this.browserCache.get('player-volume'),
|
||||
playerSoundMuted: this.browserCache.get('player-sound-muted'),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { SiteConsumer } from '../utils/contexts/';
|
||||
import VideoViewer from '../components/media-viewer/VideoViewer';
|
||||
import { _VideoMediaPage } from './_VideoMediaPage';
|
||||
|
||||
export class MediaVideoPage extends _VideoMediaPage {
|
||||
viewerContainerContent(mediaData) {
|
||||
return <>Not working anymore?</>; // TODO: check this if this page not working anymore as MediaPage.js do the same work
|
||||
return <SiteConsumer>{(site) => <VideoViewer data={mediaData} siteUrl={site.url} inEmbed={!1} />}</SiteConsumer>;
|
||||
}
|
||||
|
||||
mediaType() {
|
||||
return 'video';
|
||||
}
|
||||
}
|
||||
@@ -592,6 +592,8 @@ export class ProfileMediaPage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null,
|
||||
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null,
|
||||
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null,
|
||||
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null,
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
@@ -604,6 +606,8 @@ export class ProfileMediaPage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null,
|
||||
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null,
|
||||
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null,
|
||||
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null,
|
||||
sort_by: sortOption,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
|
||||
@@ -179,6 +179,8 @@ class ProfileSharedByMePage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
@@ -190,6 +192,8 @@ class ProfileSharedByMePage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
@@ -200,6 +204,8 @@ class ProfileSharedByMePage extends Page {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
@@ -223,6 +229,16 @@ class ProfileSharedByMePage extends Page {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
@@ -301,7 +317,9 @@ class ProfileSharedByMePage extends Page {
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = this.state.filterArgs && (
|
||||
this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=')
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state=')
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -341,7 +359,7 @@ class ProfileSharedByMePage extends Page {
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={false}
|
||||
canEdit={isMediaAuthor}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
|
||||
|
||||
@@ -177,6 +177,8 @@ export class ProfileSharedWithMePage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
@@ -188,6 +190,8 @@ export class ProfileSharedWithMePage extends Page {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
@@ -198,6 +202,8 @@ export class ProfileSharedWithMePage extends Page {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
@@ -221,6 +227,16 @@ export class ProfileSharedWithMePage extends Page {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
@@ -299,7 +315,9 @@ export class ProfileSharedWithMePage extends Page {
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = this.state.filterArgs && (
|
||||
this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=')
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state=')
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
19
frontend/src/static/js/utils/hoc/withBulkActions.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useBulkActions } from '../hooks/useBulkActions';
|
||||
|
||||
/**
|
||||
* Higher-Order Component that provides bulk actions functionality
|
||||
* to class components via props
|
||||
*/
|
||||
export function withBulkActions(WrappedComponent) {
|
||||
return function WithBulkActionsComponent(props) {
|
||||
const bulkActions = useBulkActions();
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...props}
|
||||
bulkActions={bulkActions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export function init(settings) {
|
||||
api: '',
|
||||
title: '',
|
||||
useRoundedCorners: true,
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
if (void 0 !== settings) {
|
||||
@@ -29,6 +30,10 @@ export function init(settings) {
|
||||
if ('boolean' === typeof settings.useRoundedCorners) {
|
||||
SITE.useRoundedCorners = settings.useRoundedCorners;
|
||||
}
|
||||
|
||||
if ('string' === typeof settings.version) {
|
||||
SITE.version = settings.version.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ module.exports = {
|
||||
url: process.env.MEDIACMS_URL || 'UNDEFINED_URL',
|
||||
api: process.env.MEDIACMS_API || 'UNDEFINED_API',
|
||||
useRoundedCorners: true,
|
||||
version: '1.0.0',
|
||||
theme: {
|
||||
mode: 'light', // Valid values: 'light', 'dark'.
|
||||
switch: {
|
||||
|
||||
BIN
media_files/userlogos/poster_audio.jpg
Normal file
BIN
media_files/userlogos/poster_audio.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
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
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
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
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,9 @@
|
||||
{% block headtitle %}Add subtitle - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block innercontent %}
|
||||
<div class="user-action-form-wrap">
|
||||
{% include "cms/media_nav.html" with active_tab="subtitles" %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if whisper_form %}
|
||||
<div class="user-action-form-wrap">
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
{% block headtitle %}Edit video chapters - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block topimports %}
|
||||
<link href="{% static "chapters_editor/chapters-editor.css" %}" rel="preload" as="style">
|
||||
<link href="{% static "chapters_editor/chapters-editor.css" %}" rel="stylesheet">
|
||||
|
||||
<script src="{% static 'chapters_editor/chapters-editor.js' %}"></script>
|
||||
<link href="{% static 'chapters_editor/chapters-editor.css' %}?v={{ VERSION }}" rel="preload" as="style">
|
||||
<link href="{% static 'chapters_editor/chapters-editor.css' %}?v={{ VERSION }}" rel="stylesheet">
|
||||
<script src="{% static 'chapters_editor/chapters-editor.js' %}?v={{ VERSION }}"></script>
|
||||
|
||||
<script>
|
||||
window.MEDIA_DATA = {
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
{% block headtitle %}Edit video - {{PORTAL_NAME}}{% endblock headtitle %}
|
||||
|
||||
{% block topimports %}
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="preload" as="style">
|
||||
<link href="{% static "video_editor/video-editor.css" %}" rel="stylesheet">
|
||||
|
||||
<script src="{% static 'video_editor/video-editor.js' %}"></script>
|
||||
<link href="{% static 'video_editor/video-editor.css' %}?v={{ VERSION }}" rel="preload" as="style">
|
||||
<link href="{% static 'video_editor/video-editor.css' %}?v={{ VERSION }}" rel="stylesheet">
|
||||
<script src="{% static 'video_editor/video-editor.js' %}?v={{ VERSION }}"></script>
|
||||
|
||||
<script>
|
||||
window.MEDIA_DATA = {
|
||||
|
||||
@@ -33,6 +33,9 @@ MediaCMS.features = {
|
||||
hideViews: false,
|
||||
hideAuthor: false,
|
||||
},
|
||||
listings:{
|
||||
includeNumbers: {% if INCLUDE_LISTING_NUMBERS %}true{% else %}false{% endif %},
|
||||
},
|
||||
playlists:{
|
||||
mediaTypes: ['audio', 'video'],
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ MediaCMS.site = {
|
||||
url: '{{FRONTEND_HOST}}',
|
||||
api: '{{FRONTEND_HOST}}/api/v1',
|
||||
useRoundedCorners: {% if USE_ROUNDED_CORNERS %}true{% else %}false{% endif %},
|
||||
version: '{{VERSION}}',
|
||||
theme: {
|
||||
mode: '{{DEFAULT_THEME}}',
|
||||
switch: {
|
||||
|
||||
@@ -47,6 +47,10 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"email_is_verified",
|
||||
]
|
||||
|
||||
if settings.USER_SEARCH_FIELD == "name_username_email":
|
||||
fields.append("email")
|
||||
read_only_fields.append("email")
|
||||
|
||||
if settings.USERS_NEEDS_TO_BE_APPROVED:
|
||||
fields.append("is_approved")
|
||||
read_only_fields.append("is_approved")
|
||||
|
||||
@@ -205,7 +205,7 @@ class UserList(APIView):
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[
|
||||
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
|
||||
openapi.Parameter(name='name', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='Search by name or username'),
|
||||
openapi.Parameter(name='name', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='Search by name, username, and optionally email (depending on USER_SEARCH_FIELD setting)'),
|
||||
openapi.Parameter(name='exclude_self', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_QUERY, description='Exclude current user from results'),
|
||||
],
|
||||
tags=['Users'],
|
||||
@@ -225,6 +225,9 @@ class UserList(APIView):
|
||||
|
||||
name = request.GET.get("name", "").strip()
|
||||
if name:
|
||||
if settings.USER_SEARCH_FIELD == "name_username_email":
|
||||
users = users.filter(Q(name__icontains=name) | Q(username__icontains=name) | Q(email__icontains=name))
|
||||
else: # default: name_username
|
||||
users = users.filter(Q(name__icontains=name) | Q(username__icontains=name))
|
||||
|
||||
# Exclude current user if requested
|
||||
|
||||
Reference in New Issue
Block a user