Compare commits

..

10 Commits

Author SHA1 Message Date
Yiannis Christodoulou
75b88a6f9c build assets (chapters editor) 2025-11-17 12:55:43 +02:00
Yiannis Christodoulou
f2e04cbe2d FIX: Preserve custom chapter titles when renumbering (151)
Updated the renumberAllSegments function to only update chapter titles that match the default 'Chapter X' pattern, preserving any custom titles. Also ensured segments are renumbered after updates for consistent chronological naming.
2025-11-17 12:47:16 +02:00
Markos Gogoulos
9b3d9fe1e7 trim (#1431) 2025-11-13 12:42:48 +02:00
Markos Gogoulos
ea340b6a2e V7 f4 (#1430) 2025-11-13 12:30:25 +02:00
Markos Gogoulos
ba2c31b1e6 fix: static files (#1429) 2025-11-12 14:08:02 +02:00
Yiannis Christodoulou
5eb6fafb8c fix: Show default chapter names in textarea instead of placeholder text (#1428)
* Refactor chapter filtering and auto-save logic

Simplified chapter filtering to only exclude empty titles, allowing default chapter names. Updated auto-save logic to skip saving when there are no chapters or mediaId. Removed unused helper function and improved debug logging.

* Show default chapter title in editor and set initial title

The chapter title is now always displayed in the textarea, including default names like 'Chapter 1'. Also, the initial segment is created with 'Chapter 1' as its title instead of an empty string for better clarity.

* build assets
2025-11-12 14:04:07 +02:00
Markos Gogoulos
c035bcddf5 small 7.2.x fixes 2025-11-11 19:51:42 +02:00
Markos Gogoulos
01912ea1f9 fix: adjust poster url for audio 2025-11-11 13:21:10 +02:00
Markos Gogoulos
d9f299af4d V7 small fixes (#1426) 2025-11-11 13:15:36 +02:00
Markos Gogoulos
e80590a3aa Bulk actions support (#1418) 2025-11-11 11:32:54 +02:00
45 changed files with 655 additions and 547 deletions

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/pycqa/flake8
rev: 6.0.0 rev: 6.1.0
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort

View File

@@ -570,6 +570,11 @@ ALLOW_ANONYMOUS_USER_LISTING = True
# valid choices are all, editors, admins # valid choices are all, editors, admins
CAN_SEE_MEMBERS_PAGE = "all" 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 # Maximum number of media a user can upload
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100 NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100

View File

@@ -1 +1 @@
VERSION = "7.2.3" VERSION = "7.2.1"

View File

@@ -533,12 +533,34 @@ By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can se
- **admins**, only MediaCMS admins can view the page - **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. 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. 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.29 Show or hide media count numbers on categories and tags pages ### 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: 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:

View File

@@ -178,14 +178,11 @@ class MediaPublishForm(forms.ModelForm):
state = cleaned_data.get("state") state = cleaned_data.get("state")
categories = cleaned_data.get("category") 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) rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions:
if rbac_categories and state in ['private', 'unlisted']:
# Make the confirm_state field visible and add it to the layout
self.fields['confirm_state'].widget = forms.CheckboxInput() self.fields['confirm_state'].widget = forms.CheckboxInput()
# add it after the state field
state_index = None state_index = None
for i, layout_item in enumerate(self.helper.layout): for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state': if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
@@ -198,8 +195,12 @@ class MediaPublishForm(forms.ModelForm):
self.helper.layout = Layout(*layout_items) self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'): 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:
self.add_error('confirm_state', error_message) 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 return cleaned_data

View File

@@ -910,7 +910,9 @@ def trim_video_method(media_file_path, timestamps_list):
return False return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: 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 = [] segment_files = []
for i, item in enumerate(timestamps_list): 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 single timestamp, we can use the output file directly
# For multiple timestamps, we need to create segment files # 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] cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]

View File

@@ -272,12 +272,16 @@ def show_related_media_content(media, request, limit):
category = media.category.first() category = media.category.first()
if category: if category:
q_category = Q(listable=True, category=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)) m = list(itertools.chain(m, q_res))
if len(m) < limit: if len(m) < limit:
q_generic = Q(listable=True) 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(itertools.chain(m, q_res))
m = list(set(m[:limit])) # remove duplicates 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), state=helpers.get_default_state(user=original_media.user),
is_reviewed=original_media.is_reviewed, is_reviewed=original_media.is_reviewed,
encoding_status=original_media.encoding_status, encoding_status=original_media.encoding_status,
listable=original_media.listable,
add_date=timezone.now(), add_date=timezone.now(),
video_height=original_media.video_height, video_height=original_media.video_height,
size=original_media.size, size=original_media.size,
@@ -666,11 +669,8 @@ def change_media_owner(media_id, new_user):
media.user = new_user media.user = new_user
media.save(update_fields=["user"]) media.save(update_fields=["user"])
# Update any related permissions # Optimize: Update any related permissions in bulk instead of loop
media_permissions = models.MediaPermission.objects.filter(media=media) models.MediaPermission.objects.filter(media=media).update(owner_user=new_user)
for permission in media_permissions:
permission.owner_user = new_user
permission.save(update_fields=["owner_user"])
# remove any existing permissions for the new user, since they are now owner # remove any existing permissions for the new user, since they are now owner
models.MediaPermission.objects.filter(media=media, user=new_user).delete() 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), state=helpers.get_default_state(user=media.user),
is_reviewed=media.is_reviewed, is_reviewed=media.is_reviewed,
encoding_status=media.encoding_status, encoding_status=media.encoding_status,
listable=media.listable,
add_date=timezone.now(), add_date=timezone.now(),
) )

View File

@@ -91,10 +91,10 @@ class Category(models.Model):
if self.listings_thumbnail: if self.listings_thumbnail:
return 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() media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media: if media:
return media.thumbnail_url return media.thumbnail_url
return None return None

View File

@@ -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 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() self.transcribe_function()
# Update the original values for next comparison # Update the original values for next comparison
@@ -763,6 +763,8 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_thumbnail.path) return helpers.url_from_path(self.uploaded_thumbnail.path)
if self.thumbnail: if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path) return helpers.url_from_path(self.thumbnail.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property
@@ -776,6 +778,9 @@ class Media(models.Model):
return helpers.url_from_path(self.uploaded_poster.path) return helpers.url_from_path(self.uploaded_poster.path)
if self.poster: if self.poster:
return helpers.url_from_path(self.poster.path) return helpers.url_from_path(self.poster.path)
if self.media_type == "audio":
return helpers.url_from_path("userlogos/poster_audio.jpg")
return None return None
@property @property

View File

@@ -74,10 +74,8 @@ class MediaList(APIView):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return base_queryset.filter(base_filters) return base_queryset.filter(base_filters)
# Build OR conditions for authenticated users conditions = base_filters
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user} permission_filter = {'user': request.user}
if user: if user:
permission_filter['owner_user'] = user permission_filter['owner_user'] = user
@@ -88,7 +86,6 @@ class MediaList(APIView):
perm_conditions &= Q(user=user) perm_conditions &= Q(user=user)
conditions |= perm_conditions conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False): if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member() rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories) rbac_conditions = Q(category__in=rbac_categories)
@@ -99,7 +96,6 @@ class MediaList(APIView):
return base_queryset.filter(conditions).distinct() return base_queryset.filter(conditions).distinct()
def get(self, request, format=None): def get(self, request, format=None):
# Show media
# authenticated users can see: # authenticated users can see:
# All listable media (public access) # All listable media (public access)
@@ -118,7 +114,6 @@ class MediaList(APIView):
publish_state = params.get('publish_state', '').strip() publish_state = params.get('publish_state', '').strip()
query = params.get("q", "").strip().lower() query = params.get("q", "").strip().lower()
# Handle combined sort options (e.g., title_asc, views_desc)
parsed_combined = False parsed_combined = False
if sort_by and '_' in sort_by: if sort_by and '_' in sort_by:
parts = sort_by.rsplit('_', 1) parts = sort_by.rsplit('_', 1)
@@ -237,14 +232,14 @@ class MediaList(APIView):
if not already_sorted: if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}") media = media.order_by(f"{ordering}{sort_by}")
media = media[:1000] # limit to 1000 results media = media[:1000]
paginator = pagination_class() paginator = pagination_class()
page = paginator.paginate_queryset(media, request) page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request}) serializer = MediaSerializer(page, many=True, context={"request": request})
# Collect all unique tags from the current page results
tags_set = set() tags_set = set()
for media_obj in page: for media_obj in page:
for tag in media_obj.tags.all(): for tag in media_obj.tags.all():
@@ -354,28 +349,23 @@ class MediaBulkUserActions(APIView):
}, },
) )
def post(self, request, format=None): def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated: if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED) return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', []) media_ids = request.data.get('media_ids', [])
action = request.data.get('action') action = request.data.get('action')
# Validate required parameters
if not media_ids: if not media_ids:
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
if not action: if not action:
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST) 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) media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media: if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments": if action == "enable_comments":
media.update(enable_comments=True) media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"}) return Response({"detail": f"Comments enabled for {media.count()} media items"})
@@ -446,12 +436,10 @@ class MediaBulkUserActions(APIView):
if state not in valid_states: if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST) 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 not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public": if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST) 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: for m in media:
m.state = state m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True: 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: 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) 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() media_count = media.count()
users = ( users = (
@@ -523,7 +509,6 @@ class MediaBulkUserActions(APIView):
if not usernames: if not usernames:
return Response({"detail": "users is required for set_ownership action"}, status=status.HTTP_400_BAD_REQUEST) 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) users = User.objects.filter(username__in=usernames)
if not users.exists(): if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST)
@@ -548,22 +533,17 @@ class MediaBulkUserActions(APIView):
if not usernames: if not usernames:
return Response({"detail": "users is required for remove_ownership action"}, status=status.HTTP_400_BAD_REQUEST) 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) users = User.objects.filter(username__in=usernames)
if not users.exists(): if not users.exists():
return Response({"detail": "No valid users found"}, status=status.HTTP_400_BAD_REQUEST) 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() MediaPermission.objects.filter(media__in=media, permission=ownership_type, user__in=users).delete()
return Response({"detail": "Action succeeded"}) return Response({"detail": "Action succeeded"})
elif action == "playlist_membership": elif action == "playlist_membership":
# Find playlists that contain ALL the selected media (intersection)
media_count = media.count() media_count = media.count()
# Query playlists owned by user that contain these media
results = list( results = list(
Playlist.objects.filter(user=request.user, playlistmedia__media__in=media) Playlist.objects.filter(user=request.user, playlistmedia__media__in=media)
.values('id', 'friendly_token', 'title') .values('id', 'friendly_token', 'title')
@@ -574,21 +554,15 @@ class MediaBulkUserActions(APIView):
return Response({'results': results}) return Response({'results': results})
elif action == "category_membership": elif action == "category_membership":
# Find categories that contain ALL the selected media (intersection)
media_count = media.count() 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)) 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}) return Response({'results': results})
elif action == "tag_membership": elif action == "tag_membership":
# Find tags that contain ALL the selected media (intersection)
media_count = media.count() 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)) 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}) return Response({'results': results})
@@ -605,7 +579,6 @@ class MediaBulkUserActions(APIView):
added_count = 0 added_count = 0
for category in categories: for category in categories:
for m in media: for m in media:
# Add media to category (ManyToMany relationship)
if not m.category.filter(uid=category.uid).exists(): if not m.category.filter(uid=category.uid).exists():
m.category.add(category) m.category.add(category)
added_count += 1 added_count += 1
@@ -624,7 +597,6 @@ class MediaBulkUserActions(APIView):
removed_count = 0 removed_count = 0
for category in categories: for category in categories:
for m in media: for m in media:
# Remove media from category (ManyToMany relationship)
if m.category.filter(uid=category.uid).exists(): if m.category.filter(uid=category.uid).exists():
m.category.remove(category) m.category.remove(category)
removed_count += 1 removed_count += 1
@@ -643,7 +615,6 @@ class MediaBulkUserActions(APIView):
added_count = 0 added_count = 0
for tag in tags: for tag in tags:
for m in media: for m in media:
# Add media to tag (ManyToMany relationship)
if not m.tags.filter(title=tag.title).exists(): if not m.tags.filter(title=tag.title).exists():
m.tags.add(tag) m.tags.add(tag)
added_count += 1 added_count += 1
@@ -662,7 +633,6 @@ class MediaBulkUserActions(APIView):
removed_count = 0 removed_count = 0
for tag in tags: for tag in tags:
for m in media: for m in media:
# Remove media from tag (ManyToMany relationship)
if m.tags.filter(title=tag.title).exists(): if m.tags.filter(title=tag.title).exists():
m.tags.remove(tag) m.tags.remove(tag)
removed_count += 1 removed_count += 1

View File

@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined); const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
const [isAudioFile, setIsAudioFile] = useState(false);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url); setVideoUrl(url);
// Check if the media is an audio file and set poster image // 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" // 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 mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined)); setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@@ -128,22 +130,34 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
</span> </span>
</div> </div>
{/* iOS-optimized Video Element with Native Controls */} {/* Video container with persistent background for audio files */}
<video <div className="ios-video-wrapper">
ref={(ref) => setIosVideoRef(ref)} {/* Persistent background image for audio files (Safari fix) */}
className="w-full rounded-md" {isAudioFile && posterImage && (
src={videoUrl} <div
controls className="ios-audio-poster-background"
playsInline style={{ backgroundImage: `url(${posterImage})` }}
webkit-playsinline="true" aria-hidden="true"
x-webkit-airplay="allow" />
preload="auto" )}
crossOrigin="anonymous"
poster={posterImage} {/* iOS-optimized Video Element with Native Controls */}
> <video
<source src={videoUrl} type="video/mp4" /> ref={(ref) => setIosVideoRef(ref)}
<p>Your browser doesn't support HTML5 video.</p> className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
</video> src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
</div>
{/* iOS Video Skip Controls */} {/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -268,13 +268,8 @@ const TimelineControls = ({
// Update editing title when selected segment changes // Update editing title when selected segment changes
useEffect(() => { useEffect(() => {
if (selectedSegment) { if (selectedSegment) {
// Check if the chapter title is a default generated name (e.g., "Chapter 1", "Chapter 2", etc.) // Always show the chapter title in the textarea, whether it's default or custom
const isDefaultChapterName = selectedSegment.chapterTitle && setEditingChapterTitle(selectedSegment.chapterTitle || '');
/^Chapter \d+$/.test(selectedSegment.chapterTitle);
// If it's a default name, show empty string so placeholder appears
// If it's a custom title, show the actual title
setEditingChapterTitle(isDefaultChapterName ? '' : (selectedSegment.chapterTitle || ''));
} else { } else {
setEditingChapterTitle(''); setEditingChapterTitle('');
} }
@@ -4087,4 +4082,4 @@ const TimelineControls = ({
); );
}; };
export default TimelineControls; export default TimelineControls;

View File

@@ -353,8 +353,18 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div className="video-player-container"> <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 <video
ref={videoRef} ref={videoRef}
className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
preload="metadata" preload="metadata"
crossOrigin="anonymous" crossOrigin="anonymous"
onClick={handleVideoClick} onClick={handleVideoClick}

View File

@@ -20,7 +20,7 @@ const useVideoChapters = () => {
// Sort by start time to find chronological position // Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment // 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}`; return `Chapter ${chapterIndex + 1}`;
}; };
@@ -28,12 +28,18 @@ const useVideoChapters = () => {
const renumberAllSegments = (segments: Segment[]): Segment[] => { const renumberAllSegments = (segments: Segment[]): Segment[] => {
// Sort segments by start time // Sort segments by start time
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position // 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
...segment, return sortedSegments.map((segment, index) => {
chapterTitle: `Chapter ${index + 1}` const currentTitle = segment.chapterTitle || '';
})); const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
return {
...segment,
chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
};
});
}; };
// Helper function to parse time string (HH:MM:SS.mmm) to seconds // Helper function to parse time string (HH:MM:SS.mmm) to seconds
@@ -124,9 +130,7 @@ const useVideoChapters = () => {
let initialSegments: Segment[] = []; let initialSegments: Segment[] = [];
// Check if we have existing chapters from the backend // Check if we have existing chapters from the backend
const existingChapters = const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
[];
if (existingChapters.length > 0) { if (existingChapters.length > 0) {
// Create segments from existing chapters // Create segments from existing chapters
@@ -150,7 +154,7 @@ const useVideoChapters = () => {
// Create a default segment that spans the entire video on first load // Create a default segment that spans the entire video on first load
const initialSegment: Segment = { const initialSegment: Segment = {
id: 1, id: 1,
chapterTitle: '', chapterTitle: 'Chapter 1',
startTime: 0, startTime: 0,
endTime: video.duration, endTime: video.duration,
}; };
@@ -225,7 +229,7 @@ const useVideoChapters = () => {
logger.debug('Adding Safari-specific event listeners for audio support'); logger.debug('Adding Safari-specific event listeners for audio support');
video.addEventListener('canplay', handleCanPlay); video.addEventListener('canplay', handleCanPlay);
video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('loadeddata', handleLoadedData);
// Additional timeout fallback for Safari audio files // Additional timeout fallback for Safari audio files
const safariTimeout = setTimeout(() => { const safariTimeout = setTimeout(() => {
if (video.duration && duration === 0) { if (video.duration && duration === 0) {
@@ -261,21 +265,21 @@ const useVideoChapters = () => {
useEffect(() => { useEffect(() => {
if (isSafari() && videoRef.current) { if (isSafari() && videoRef.current) {
const video = videoRef.current; const video = videoRef.current;
const initializeSafariOnInteraction = () => { const initializeSafariOnInteraction = () => {
// Try to load video metadata by attempting to play and immediately pause // Try to load video metadata by attempting to play and immediately pause
const attemptInitialization = async () => { const attemptInitialization = async () => {
try { try {
logger.debug('Safari: Attempting auto-initialization on user interaction'); logger.debug('Safari: Attempting auto-initialization on user interaction');
// Briefly play to trigger metadata loading, then pause // Briefly play to trigger metadata loading, then pause
await video.play(); await video.play();
video.pause(); video.pause();
// Check if we now have duration and initialize if needed // Check if we now have duration and initialize if needed
if (video.duration > 0 && clipSegments.length === 0) { if (video.duration > 0 && clipSegments.length === 0) {
logger.debug('Safari: Successfully initialized metadata, creating default segment'); logger.debug('Safari: Successfully initialized metadata, creating default segment');
const defaultSegment: Segment = { const defaultSegment: Segment = {
id: 1, id: 1,
chapterTitle: '', chapterTitle: '',
@@ -286,14 +290,14 @@ const useVideoChapters = () => {
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
setClipSegments([defaultSegment]); setClipSegments([defaultSegment]);
const initialState: EditorState = { const initialState: EditorState = {
trimStart: 0, trimStart: 0,
trimEnd: video.duration, trimEnd: video.duration,
splitPoints: [], splitPoints: [],
clipSegments: [defaultSegment], clipSegments: [defaultSegment],
}; };
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
} }
@@ -315,7 +319,7 @@ const useVideoChapters = () => {
// Add listeners for various user interactions // Add listeners for various user interactions
document.addEventListener('click', handleUserInteraction); document.addEventListener('click', handleUserInteraction);
document.addEventListener('keydown', handleUserInteraction); document.addEventListener('keydown', handleUserInteraction);
return () => { return () => {
document.removeEventListener('click', handleUserInteraction); document.removeEventListener('click', handleUserInteraction);
document.removeEventListener('keydown', handleUserInteraction); document.removeEventListener('keydown', handleUserInteraction);
@@ -332,7 +336,7 @@ const useVideoChapters = () => {
// This play/pause will trigger metadata loading in Safari // This play/pause will trigger metadata loading in Safari
await video.play(); await video.play();
video.pause(); video.pause();
// The metadata events should fire now and initialize segments // The metadata events should fire now and initialize segments
return true; return true;
} catch (error) { } catch (error) {
@@ -564,8 +568,11 @@ const useVideoChapters = () => {
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}` `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 // Update segment state immediately for UI feedback
setClipSegments(e.detail.segments); setClipSegments(renumberedSegments);
// Always save state to history for non-intermediate actions // Always save state to history for non-intermediate actions
if (isSignificantChange) { if (isSignificantChange) {
@@ -573,7 +580,7 @@ const useVideoChapters = () => {
// ensure we capture the state properly // ensure we capture the state properly
setTimeout(() => { setTimeout(() => {
// Deep clone to ensure state is captured correctly // 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 // Create a complete state snapshot
const stateWithAction: EditorState = { const stateWithAction: EditorState = {
@@ -919,10 +926,10 @@ const useVideoChapters = () => {
const singleChapter = backendChapters[0]; const singleChapter = backendChapters[0];
const startSeconds = parseTimeToSeconds(singleChapter.startTime); const startSeconds = parseTimeToSeconds(singleChapter.startTime);
const endSeconds = parseTimeToSeconds(singleChapter.endTime); const endSeconds = parseTimeToSeconds(singleChapter.endTime);
// Check if this single chapter spans the entire video (within 0.1 second tolerance) // Check if this single chapter spans the entire video (within 0.1 second tolerance)
const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1; const isFullVideoChapter = startSeconds <= 0.1 && Math.abs(endSeconds - duration) <= 0.1;
if (isFullVideoChapter) { if (isFullVideoChapter) {
logger.debug('Manual save: Single chapter spans full video - sending empty array'); logger.debug('Manual save: Single chapter spans full video - sending empty array');
backendChapters = []; backendChapters = [];

View File

@@ -8,12 +8,40 @@
overflow: hidden; 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 { .ios-video-player-container video {
position: relative;
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 360px; max-height: 360px;
aspect-ratio: 16/9; aspect-ratio: 16/9;
background-color: black; }
/* 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 { .ios-time-display {

View File

@@ -76,10 +76,26 @@
user-select: none; 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 { .video-player-container video {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
z-index: 2;
/* Force hardware acceleration */ /* Force hardware acceleration */
transform: translateZ(0); transform: translateZ(0);
-webkit-transform: translateZ(0); -webkit-transform: translateZ(0);
@@ -88,6 +104,11 @@
user-select: none; 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 */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.video-player-container video { .video-player-container video {
@@ -109,6 +130,7 @@
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
z-index: 3;
} }
.video-player-container:hover .play-pause-indicator { .video-player-container:hover .play-pause-indicator {
@@ -187,6 +209,7 @@
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
z-index: 3;
} }
.video-player-container:hover .video-controls { .video-player-container:hover .video-controls {

View File

@@ -13,6 +13,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
const [videoUrl, setVideoUrl] = useState<string>(''); const [videoUrl, setVideoUrl] = useState<string>('');
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null); const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
const [posterImage, setPosterImage] = useState<string | undefined>(undefined); const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
const [isAudioFile, setIsAudioFile] = useState(false);
// Refs for hold-to-continue functionality // Refs for hold-to-continue functionality
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null); const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -41,12 +42,13 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url); setVideoUrl(url);
// Check if the media is an audio file and set poster image // 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" // 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 mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined)); setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]); }, [videoRef]);
// Function to jump 15 seconds backward // Function to jump 15 seconds backward
@@ -128,22 +130,34 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
</span> </span>
</div> </div>
{/* iOS-optimized Video Element with Native Controls */} {/* Video container with persistent background for audio files */}
<video <div className="ios-video-wrapper">
ref={(ref) => setIosVideoRef(ref)} {/* Persistent background image for audio files (Safari fix) */}
className="w-full rounded-md" {isAudioFile && posterImage && (
src={videoUrl} <div
controls className="ios-audio-poster-background"
playsInline style={{ backgroundImage: `url(${posterImage})` }}
webkit-playsinline="true" aria-hidden="true"
x-webkit-airplay="allow" />
preload="auto" )}
crossOrigin="anonymous"
poster={posterImage} {/* iOS-optimized Video Element with Native Controls */}
> <video
<source src={videoUrl} type="video/mp4" /> ref={(ref) => setIosVideoRef(ref)}
<p>Your browser doesn't support HTML5 video.</p> className={`w-full rounded-md ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
</video> src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
</div>
{/* iOS Video Skip Controls */} {/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4"> <div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -47,14 +47,24 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== ''; const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined); const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
// Detect iOS device // Detect iOS device and Safari browser
useEffect(() => { useEffect(() => {
const checkIOS = () => { const checkIOS = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
}; };
const checkSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
setIsIOS(checkIOS()); setIsIOS(checkIOS());
// Store Safari detection globally for other components
if (typeof window !== 'undefined') {
(window as any).isSafari = checkSafari();
}
// Check if video was previously initialized // Check if video was previously initialized
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -343,9 +353,19 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
return ( return (
<div className="video-player-container"> <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 <video
ref={videoRef} ref={videoRef}
preload="auto" className={isAudioFile && posterImage ? 'audio-with-poster' : ''}
preload="metadata"
crossOrigin="anonymous" crossOrigin="anonymous"
onClick={handleVideoClick} onClick={handleVideoClick}
playsInline playsInline
@@ -356,7 +376,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
poster={posterImage} poster={posterImage}
> >
<source src={sampleVideoUrl} type="video/mp4" /> <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> </video>
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */} {/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}

View File

@@ -8,12 +8,40 @@
overflow: hidden; 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 { .ios-video-player-container video {
position: relative;
width: 100%; width: 100%;
height: auto; height: auto;
max-height: 360px; max-height: 360px;
aspect-ratio: 16/9; aspect-ratio: 16/9;
background-color: black; }
/* 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 { .ios-time-display {

View File

@@ -76,10 +76,26 @@
user-select: none; 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 { .video-player-container video {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
z-index: 2;
/* Force hardware acceleration */ /* Force hardware acceleration */
transform: translateZ(0); transform: translateZ(0);
-webkit-transform: translateZ(0); -webkit-transform: translateZ(0);
@@ -88,6 +104,11 @@
user-select: none; 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 */ /* iOS-specific styles */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.video-player-container video { .video-player-container video {
@@ -109,6 +130,7 @@
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
pointer-events: none; pointer-events: none;
z-index: 3;
} }
.video-player-container:hover .play-pause-indicator { .video-player-container:hover .play-pause-indicator {
@@ -187,6 +209,7 @@
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
z-index: 3;
} }
.video-player-container:hover .video-controls { .video-player-container:hover .video-controls {

View File

@@ -20,6 +20,7 @@ class CustomChaptersOverlay extends Component {
this.touchStartTime = 0; this.touchStartTime = 0;
this.touchThreshold = 150; // ms for tap vs scroll detection this.touchThreshold = 150; // ms for tap vs scroll detection
this.isSmallScreen = window.innerWidth <= 480; this.isSmallScreen = window.innerWidth <= 480;
this.scrollY = 0; // Track scroll position before locking
// Bind methods // Bind methods
this.createOverlay = this.createOverlay.bind(this); this.createOverlay = this.createOverlay.bind(this);
@@ -31,6 +32,8 @@ class CustomChaptersOverlay extends Component {
this.handleMobileInteraction = this.handleMobileInteraction.bind(this); this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
this.setupResizeListener = this.setupResizeListener.bind(this); this.setupResizeListener = this.setupResizeListener.bind(this);
this.handleResize = this.handleResize.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 // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
@@ -65,6 +68,9 @@ class CustomChaptersOverlay extends Component {
const el = this.player().el(); const el = this.player().el();
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
} }
setupResizeListener() { setupResizeListener() {
@@ -164,6 +170,8 @@ class CustomChaptersOverlay extends Component {
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
const el = this.player().el(); const el = this.player().el();
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
}; };
chapterClose.appendChild(closeBtn); chapterClose.appendChild(closeBtn);
playlistTitle.appendChild(chapterClose); 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() { toggleOverlay() {
if (!this.overlay) return; if (!this.overlay) return;
@@ -369,17 +408,11 @@ class CustomChaptersOverlay extends Component {
navigator.vibrate(30); navigator.vibrate(30);
} }
// Prevent body scroll on mobile when overlay is open // Lock/unlock body scroll on mobile when overlay opens/closes
if (this.isMobile) { if (isHidden) {
if (isHidden) { this.lockBodyScroll();
document.body.style.overflow = 'hidden'; } else {
document.body.style.position = 'fixed'; this.unlockBodyScroll();
document.body.style.width = '100%';
} else {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
try { try {
@@ -390,7 +423,9 @@ class CustomChaptersOverlay extends Component {
m.classList.remove('vjs-lock-showing'); m.classList.remove('vjs-lock-showing');
m.style.display = 'none'; m.style.display = 'none';
}); });
} catch (e) {} } catch {
// Ignore errors when closing menus
}
} }
updateCurrentChapter() { updateCurrentChapter() {
@@ -406,7 +441,6 @@ class CustomChaptersOverlay extends Component {
currentTime >= chapter.startTime && currentTime >= chapter.startTime &&
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].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'); const dynamic = item.querySelector('.meta-dynamic');
if (isPlaying) { if (isPlaying) {
currentChapterIndex = index; currentChapterIndex = index;
@@ -463,11 +497,7 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile // Restore body scroll on mobile
if (this.isMobile) { this.unlockBodyScroll();
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
} }
@@ -479,11 +509,7 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open'); if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when disposing // Restore body scroll on mobile when disposing
if (this.isMobile) { this.unlockBodyScroll();
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Clean up event listeners // Clean up event listeners
if (this.handleResize) { if (this.handleResize) {

View File

@@ -25,6 +25,7 @@ class CustomSettingsMenu extends Component {
this.isMobile = this.detectMobile(); this.isMobile = this.detectMobile();
this.isSmallScreen = window.innerWidth <= 480; this.isSmallScreen = window.innerWidth <= 480;
this.touchThreshold = 150; // ms for tap vs scroll detection this.touchThreshold = 150; // ms for tap vs scroll detection
this.scrollY = 0; // Track scroll position before locking
// Bind methods // Bind methods
this.createSettingsButton = this.createSettingsButton.bind(this); this.createSettingsButton = this.createSettingsButton.bind(this);
@@ -41,6 +42,8 @@ class CustomSettingsMenu extends Component {
this.detectMobile = this.detectMobile.bind(this); this.detectMobile = this.detectMobile.bind(this);
this.handleMobileInteraction = this.handleMobileInteraction.bind(this); this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
this.setupResizeListener = this.setupResizeListener.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 // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
@@ -656,6 +659,8 @@ class CustomSettingsMenu extends Component {
if (btnEl) { if (btnEl) {
btnEl.classList.remove('settings-clicked'); btnEl.classList.remove('settings-clicked');
} }
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
}; };
closeButton.addEventListener('click', closeFunction); closeButton.addEventListener('click', closeFunction);
@@ -942,6 +947,37 @@ class CustomSettingsMenu extends Component {
this.startSubtitleSync(); 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) { toggleSettings(e) {
// e.stopPropagation(); // e.stopPropagation();
const isVisible = this.settingsOverlay.classList.contains('show'); const isVisible = this.settingsOverlay.classList.contains('show');
@@ -954,11 +990,7 @@ class CustomSettingsMenu extends Component {
this.stopKeepingControlsVisible(); this.stopKeepingControlsVisible();
// Restore body scroll on mobile when closing // Restore body scroll on mobile when closing
if (this.isMobile) { this.unlockBodyScroll();
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} else { } else {
this.settingsOverlay.classList.add('show'); this.settingsOverlay.classList.add('show');
this.settingsOverlay.style.display = 'block'; this.settingsOverlay.style.display = 'block';
@@ -972,11 +1004,7 @@ class CustomSettingsMenu extends Component {
} }
// Prevent body scroll on mobile when overlay is open // Prevent body scroll on mobile when overlay is open
if (this.isMobile) { this.lockBodyScroll();
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
} }
this.speedSubmenu.style.display = 'none'; // Hide submenu when main menu toggles 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.classList.add('show');
this.settingsOverlay.style.display = 'block'; this.settingsOverlay.style.display = 'block';
// Lock body scroll when opening
this.lockBodyScroll();
// Hide other submenus and show subtitles submenu // Hide other submenus and show subtitles submenu
this.speedSubmenu.style.display = 'none'; this.speedSubmenu.style.display = 'none';
if (this.qualitySubmenu) this.qualitySubmenu.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 // Restore body scroll on mobile when closing
if (this.isMobile) { this.unlockBodyScroll();
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} }
} }
@@ -1417,6 +1444,8 @@ class CustomSettingsMenu extends Component {
if (btnEl) { if (btnEl) {
btnEl.classList.remove('settings-clicked'); 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 // Restore body scroll on mobile when disposing
if (this.isMobile) { this.unlockBodyScroll();
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Remove DOM elements // Remove DOM elements
if (this.settingsOverlay) { if (this.settingsOverlay) {

View File

@@ -49,10 +49,7 @@ class EndScreenOverlay extends Component {
// Get videos to show - access directly from options during createEl // Get videos to show - access directly from options during createEl
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || []; const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
const videosToShow = const videosToShow = relatedVideos.slice(0, maxVideos);
relatedVideos.length > 0
? relatedVideos.slice(0, maxVideos)
: this.createSampleVideos().slice(0, maxVideos);
if (useSwiper) { if (useSwiper) {
return this.createSwiperGrid(videosToShow, itemsPerView || 2, columns, gridRows || 1); return this.createSwiperGrid(videosToShow, itemsPerView || 2, columns, gridRows || 1);
@@ -307,8 +304,8 @@ class EndScreenOverlay extends Component {
if (this.relatedVideos && Array.isArray(this.relatedVideos) && this.relatedVideos.length > 0) { if (this.relatedVideos && Array.isArray(this.relatedVideos) && this.relatedVideos.length > 0) {
return this.relatedVideos.slice(0, maxVideos); return this.relatedVideos.slice(0, maxVideos);
} }
// Fallback to sample videos for testing // Return empty array if no related videos
return this.createSampleVideos().slice(0, maxVideos); return [];
} }
createVideoItem(video, isSwiperMode = false, itemsPerView = 2, isGridMode = false) { createVideoItem(video, isSwiperMode = false, itemsPerView = 2, isGridMode = false) {
@@ -745,153 +742,12 @@ class EndScreenOverlay extends Component {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0; 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() { show() {
this.el().style.display = 'flex'; // Only show if there are related videos
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
if (relatedVideos.length > 0) {
this.el().style.display = 'flex';
}
} }
hide() { hide() {

View File

@@ -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) { @media (max-width: 500px) {
.video-js .vjs-control-bar .vjs-next-video-button { .video-js .vjs-control-bar .vjs-next-video-button {
display: none !important; display: none !important;

View File

@@ -5,6 +5,7 @@ import { translateString } from '../utils/helpers/';
interface User { interface User {
name: string; name: string;
username: string; username: string;
email?: string;
} }
interface BulkActionChangeOwnerModalProps { interface BulkActionChangeOwnerModalProps {
@@ -76,7 +77,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
const handleUserSelect = (user: User) => { const handleUserSelect = (user: User) => {
setSelectedUser(user); setSelectedUser(user);
setSearchTerm(user.name + ' - ' + user.username); setSearchTerm(user.name + ' - ' + (user.email || user.username));
setSearchResults([]); setSearchResults([]);
}; };
@@ -147,7 +148,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
className="search-result-item" className="search-result-item"
onClick={() => handleUserSelect(user)} onClick={() => handleUserSelect(user)}
> >
{user.name} - {user.username} {user.name} - {user.email || user.username}
</div> </div>
))} ))}
</div> </div>
@@ -156,7 +157,7 @@ export const BulkActionChangeOwnerModal: React.FC<BulkActionChangeOwnerModalProp
{selectedUser && ( {selectedUser && (
<div className="selected-user"> <div className="selected-user">
<span>{translateString('Selected')}: {selectedUser.name} - {selectedUser.username}</span> <span>{translateString('Selected')}: {selectedUser.name} - {selectedUser.email || selectedUser.username}</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { translateString } from '../utils/helpers/';
interface User { interface User {
name: string; name: string;
username: string; username: string;
email?: string;
} }
interface BulkActionPermissionModalProps { interface BulkActionPermissionModalProps {
@@ -28,7 +29,7 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
}) => { }) => {
const [existingUsers, setExistingUsers] = useState<string[]>([]); const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [existingSearchTerm, setExistingSearchTerm] = useState(''); const [existingSearchTerm, setExistingSearchTerm] = useState('');
const [usersToAdd, setUsersToAdd] = useState<string[]>([]); const [usersToAdd, setUsersToAdd] = useState<Array<{ username: string; display: string }>>([]);
const [usersToRemove, setUsersToRemove] = useState<string[]>([]); const [usersToRemove, setUsersToRemove] = useState<string[]>([]);
const [searchResults, setSearchResults] = useState<User[]>([]); const [searchResults, setSearchResults] = useState<User[]>([]);
const [addSearchTerm, setAddSearchTerm] = useState(''); const [addSearchTerm, setAddSearchTerm] = useState('');
@@ -124,17 +125,17 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
setSearchTimeout(timeout); setSearchTimeout(timeout);
}; };
const addUserToList = (username: string, name: string) => { const addUserToList = (username: string, name: string, email?: string) => {
const userDisplay = `${name} - ${username}`; const userDisplay = `${name} - ${email || username}`;
if (!usersToAdd.includes(userDisplay) && !existingUsers.includes(userDisplay)) { if (!usersToAdd.some(u => u.username === username) && !existingUsers.includes(userDisplay)) {
setUsersToAdd([...usersToAdd, userDisplay]); setUsersToAdd([...usersToAdd, { username, display: userDisplay }]);
setAddSearchTerm(''); setAddSearchTerm('');
setSearchResults([]); setSearchResults([]);
} }
}; };
const removeUserFromAddList = (user: string) => { const removeUserFromAddList = (username: string) => {
setUsersToAdd(usersToAdd.filter((u) => u !== user)); setUsersToAdd(usersToAdd.filter((u) => u.username !== username));
}; };
const markUserForRemoval = (user: string) => { const markUserForRemoval = (user: string) => {
@@ -148,7 +149,8 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
}; };
const extractUsername = (userDisplay: string): string => { 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(' - '); const parts = userDisplay.split(' - ');
return parts.length > 1 ? parts[parts.length - 1] : userDisplay; return parts.length > 1 ? parts[parts.length - 1] : userDisplay;
}; };
@@ -159,7 +161,7 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
try { try {
// First, add users if any // First, add users if any
if (usersToAdd.length > 0) { 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', { const addResponse = await fetch('/api/v1/media/user/bulk_actions', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -249,9 +251,9 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
<div <div
key={user.username} key={user.username}
className="search-result-item" 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>
))} ))}
</div> </div>
@@ -263,9 +265,9 @@ export const BulkActionPermissionModal: React.FC<BulkActionPermissionModalProps>
<div className="empty-message">{translateString('No users to add')}</div> <div className="empty-message">{translateString('No users to add')}</div>
) : ( ) : (
usersToAdd.map((user) => ( usersToAdd.map((user) => (
<div key={user} className="user-item"> <div key={user.username} className="user-item">
<span>{user}</span> <span>{user.display}</span>
<button className="remove-btn" onClick={() => removeUserFromAddList(user)}> <button className="remove-btn" onClick={() => removeUserFromAddList(user.username)}>
× ×
</button> </button>
</div> </div>

View File

@@ -26,17 +26,12 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
csrfToken, csrfToken,
}) => { }) => {
const [selectedState, setSelectedState] = useState('public'); const [selectedState, setSelectedState] = useState('public');
const [initialState, setInitialState] = useState('public');
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
// Reset state when modal closes // Reset state when modal closes
setSelectedState('public'); setSelectedState('public');
setInitialState('public');
} else {
// When modal opens, set initial state
setInitialState('public');
} }
}, [isOpen]); }, [isOpen]);
@@ -79,7 +74,9 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
if (!isOpen) return null; 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 ( return (
<div className="publish-state-modal-overlay"> <div className="publish-state-modal-overlay">
@@ -116,7 +113,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
<button <button
className="publish-state-btn publish-state-btn-submit" className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isProcessing || !hasStateChanged} disabled={isProcessing}
> >
{isProcessing ? translateString('Processing...') : translateString('Submit')} {isProcessing ? translateString('Processing...') : translateString('Submit')}
</button> </button>

View File

@@ -21,12 +21,16 @@ function downloadOptionsList() {
for (g in encodings_info[k]) { for (g in encodings_info[k]) {
if (encodings_info[k].hasOwnProperty(g)) { 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) { 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] = { optionsList[encodings_info[k][g].title] = {
text: k + ' - ' + g.toUpperCase() + ' (' + encodings_info[k][g].size + ')', 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: { linkAttr: {
target: '_blank', 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 = { optionsList.original_media_url = {
text: 'Original file (' + media_data.size + ')', text: 'Original file (' + media_data.size + ')',
link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url), link: formatInnerLink(media_data.original_media_url, SiteContext._currentValue.url),
linkAttr: { linkAttr: {
target: '_blank', target: '_blank',
download: media_data.title, download: originalFilename,
}, },
}; };

View File

@@ -54,6 +54,10 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url) ? formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url)
: null; : 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); this.updateStateValues = this.updateStateValues.bind(this);
} }
@@ -171,7 +175,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View File

@@ -77,7 +77,7 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
.downloadLink ? ( .downloadLink ? (
<VideoMediaDownloadLink /> <VideoMediaDownloadLink />
) : ( ) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} /> <OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
)} )}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} /> <MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View 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}
/>
);
};
}

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

View File

@@ -47,6 +47,10 @@ class UserSerializer(serializers.ModelSerializer):
"email_is_verified", "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: if settings.USERS_NEEDS_TO_BE_APPROVED:
fields.append("is_approved") fields.append("is_approved")
read_only_fields.append("is_approved") read_only_fields.append("is_approved")

View File

@@ -205,7 +205,7 @@ class UserList(APIView):
@swagger_auto_schema( @swagger_auto_schema(
manual_parameters=[ manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'), 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'), openapi.Parameter(name='exclude_self', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_QUERY, description='Exclude current user from results'),
], ],
tags=['Users'], tags=['Users'],
@@ -225,7 +225,10 @@ class UserList(APIView):
name = request.GET.get("name", "").strip() name = request.GET.get("name", "").strip()
if name: if name:
users = users.filter(Q(name__icontains=name) | Q(username__icontains=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 # Exclude current user if requested
exclude_self = request.GET.get("exclude_self", "") == "True" exclude_self = request.GET.get("exclude_self", "") == "True"