Compare commits

..

4 Commits

Author SHA1 Message Date
Markos Gogoulos
3abc012de1 next 2025-11-04 15:38:35 +02:00
Yiannis Christodoulou
fef262496d fix: Show autoplay button everywhere, Remove fake related items, Trim audio on Safari (#1424) 2025-11-04 09:38:39 +02:00
Markos Gogoulos
3e79f5a558 fixes 2025-10-29 15:25:08 +02:00
Markos Gogoulos
a320375e16 Bulk actions support
3wtv
2025-10-28 15:24:29 +02:00
42 changed files with 273 additions and 533 deletions

1
.gitignore vendored
View File

@@ -35,4 +35,3 @@ frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3
templates/todo-MS4.md

View File

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

View File

@@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
## Support and paid services
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information.
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
### Commercial Hostings
**Elestio**

View File

@@ -1 +1 @@
VERSION = "7.3.0"
VERSION = "7.1.0"

View File

@@ -178,11 +178,14 @@ class MediaPublishForm(forms.ModelForm):
state = cleaned_data.get("state")
categories = cleaned_data.get("category")
if state in ['private', 'unlisted']:
custom_permissions = self.instance.permissions.exists()
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
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()
# 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':
@@ -195,12 +198,8 @@ class MediaPublishForm(forms.ModelForm):
self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'):
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)
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)}"
self.add_error('confirm_state', error_message)
return cleaned_data

View File

@@ -910,9 +910,7 @@ def trim_video_method(media_file_path, timestamps_list):
return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
# 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}")
output_file = os.path.join(temp_dir, "output.mp4")
segment_files = []
for i, item in enumerate(timestamps_list):
@@ -922,7 +920,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}{input_ext}")
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
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,16 +272,12 @@ def show_related_media_content(media, request, limit):
category = media.category.first()
if category:
q_category = Q(listable=True, category=category)
# 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]
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]
m = list(itertools.chain(m, q_res))
if len(m) < limit:
q_generic = Q(listable=True)
# 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]
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]
m = list(itertools.chain(m, q_res))
m = list(set(m[:limit])) # remove duplicates
@@ -494,6 +490,7 @@ 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,
@@ -669,8 +666,11 @@ def change_media_owner(media_id, new_user):
media.user = new_user
media.save(update_fields=["user"])
# Optimize: Update any related permissions in bulk instead of loop
models.MediaPermission.objects.filter(media=media).update(owner_user=new_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"])
# remove any existing permissions for the new user, since they are now owner
models.MediaPermission.objects.filter(media=media, user=new_user).delete()
@@ -713,6 +713,7 @@ 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(),
)

View File

@@ -91,10 +91,10 @@ class Category(models.Model):
if self.listings_thumbnail:
return self.listings_thumbnail
# 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
if Media.objects.filter(category=self, state="public").exists():
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
if media:
return media.thumbnail_url
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
)
if transcription_changed and self.media_type in ["video", "audio"]:
if transcription_changed and self.media_type == "video":
self.transcribe_function()
# Update the original values for next comparison
@@ -329,17 +329,10 @@ class Media(models.Model):
if to_transcribe:
TranscriptionRequest.objects.create(media=self, translate_to_english=False)
tasks.whisper_transcribe.apply_async(
args=[self.friendly_token, False],
countdown=10,
)
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False)
if to_transcribe_and_translate:
TranscriptionRequest.objects.create(media=self, translate_to_english=True)
tasks.whisper_transcribe.apply_async(
args=[self.friendly_token, True],
countdown=10,
)
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True)
def update_search_vector(self):
"""
@@ -770,8 +763,6 @@ 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
@@ -785,9 +776,6 @@ 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

View File

@@ -74,8 +74,10 @@ class MediaList(APIView):
if not request.user.is_authenticated:
return base_queryset.filter(base_filters)
conditions = base_filters
# Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user}
if user:
permission_filter['owner_user'] = user
@@ -86,6 +88,7 @@ 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)
@@ -96,6 +99,7 @@ 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)
@@ -114,6 +118,7 @@ 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)
@@ -232,14 +237,14 @@ class MediaList(APIView):
if not already_sorted:
media = media.order_by(f"{ordering}{sort_by}")
media = media[:1000]
media = media[:1000] # limit to 1000 results
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():
@@ -349,23 +354,28 @@ 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"})
@@ -436,10 +446,12 @@ 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:
@@ -483,6 +495,8 @@ 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 = (
@@ -509,6 +523,7 @@ 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)
@@ -533,17 +548,22 @@ 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')
@@ -554,15 +574,21 @@ 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})
@@ -579,6 +605,7 @@ 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
@@ -597,6 +624,7 @@ 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
@@ -615,6 +643,7 @@ 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
@@ -633,6 +662,7 @@ 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

View File

@@ -150,11 +150,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Add Chapters</h2>
</div>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}

View File

@@ -13,7 +13,6 @@ 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);
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
setIsAudioFile(audioFile);
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]);
// Function to jump 15 seconds backward
@@ -130,34 +128,22 @@ 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 ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
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-optimized Video Element with Native Controls */}
<video
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -268,8 +268,13 @@ const TimelineControls = ({
// Update editing title when selected segment changes
useEffect(() => {
if (selectedSegment) {
// Always show the chapter title in the textarea, whether it's default or custom
setEditingChapterTitle(selectedSegment.chapterTitle || '');
// 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 || ''));
} else {
setEditingChapterTitle('');
}
@@ -4082,4 +4087,4 @@ const TimelineControls = ({
);
};
export default TimelineControls;
export default TimelineControls;

View File

@@ -353,18 +353,8 @@ 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}

View File

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

View File

@@ -8,40 +8,12 @@
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;
}
/* Make video transparent only for audio files with poster so background shows through */
.ios-video-player-container video.audio-with-poster {
background-color: transparent;
background-color: black;
}
.ios-time-display {

View File

@@ -1,16 +1,4 @@
#chapters-editor-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #059669;
margin: 0;
}
.timeline-container-card {
background-color: white;
border-radius: 0.5rem;
@@ -23,8 +11,6 @@
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(16, 185, 129, 0.2);
}
.timeline-title {
@@ -35,8 +21,6 @@
.timeline-title-text {
font-weight: 700;
color: #059669;
font-size: 0.875rem;
}
.current-time {
@@ -64,11 +48,10 @@
.timeline-container {
position: relative;
min-width: 100%;
background-color: #E2EDE4;
background-color: #fafbfc;
height: 70px;
border-radius: 0.25rem;
overflow: visible !important;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.timeline-marker {
@@ -211,7 +194,7 @@
left: 0;
right: 0;
padding: 0.4rem;
background-color: rgba(16, 185, 129, 0.6);
background-color: rgba(0, 0, 0, 0.4);
color: white;
opacity: 1;
transition: background-color 0.2s;
@@ -219,15 +202,15 @@
}
.clip-segment:hover .clip-segment-info {
background-color: rgba(16, 185, 129, 0.7);
background-color: rgba(0, 0, 0, 0.5);
}
.clip-segment.selected .clip-segment-info {
background-color: rgba(5, 150, 105, 0.8);
background-color: rgba(59, 130, 246, 0.5);
}
.clip-segment.selected:hover .clip-segment-info {
background-color: rgba(5, 150, 105, 0.75);
background-color: rgba(59, 130, 246, 0.4);
}
.clip-segment-name {

View File

@@ -76,26 +76,10 @@
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);
@@ -104,11 +88,6 @@
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 {
@@ -130,7 +109,6 @@
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 3;
}
.video-player-container:hover .play-pause-indicator {
@@ -209,7 +187,6 @@
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 {

View File

@@ -309,11 +309,6 @@ const App = () => {
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Trim or Split</h2>
</div>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}

View File

@@ -13,7 +13,6 @@ 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);
@@ -42,13 +41,12 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
setVideoUrl(url);
// Check if the media is an audio file and set poster image
const audioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
setIsAudioFile(audioFile);
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
setPosterImage(isValidPoster ? mediaPosterUrl : (audioFile ? AUDIO_POSTER_URL : undefined));
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
}, [videoRef]);
// Function to jump 15 seconds backward
@@ -130,34 +128,22 @@ 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 ${isAudioFile && posterImage ? 'audio-with-poster' : ''}`}
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-optimized Video Element with Native Controls */}
<video
ref={(ref) => setIosVideoRef(ref)}
className="w-full rounded-md"
src={videoUrl}
controls
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
preload="auto"
crossOrigin="anonymous"
poster={posterImage}
>
<source src={videoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* iOS Video Skip Controls */}
<div className="ios-skip-controls mt-3 flex justify-center gap-4">

View File

@@ -353,18 +353,8 @@ 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}

View File

@@ -8,40 +8,12 @@
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;
}
/* Make video transparent only for audio files with poster so background shows through */
.ios-video-player-container video.audio-with-poster {
background-color: transparent;
background-color: black;
}
.ios-time-display {

View File

@@ -1,16 +1,4 @@
#video-editor-trim-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #2563eb;
margin: 0;
}
.timeline-container-card {
background-color: white;
border-radius: 0.5rem;
@@ -23,8 +11,6 @@
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(59, 130, 246, 0.2);
}
.timeline-title {
@@ -35,8 +21,6 @@
.timeline-title-text {
font-weight: 700;
color: #2563eb;
font-size: 0.875rem;
}
.current-time {
@@ -64,11 +48,10 @@
.timeline-container {
position: relative;
min-width: 100%;
background-color: #eff6ff;
background-color: #fafbfc;
height: 70px;
border-radius: 0.25rem;
overflow: visible !important;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.timeline-marker {
@@ -211,7 +194,7 @@
left: 0;
right: 0;
padding: 0.4rem;
background-color: rgba(59, 130, 246, 0.6);
background-color: rgba(0, 0, 0, 0.4);
color: white;
opacity: 1;
transition: background-color 0.2s;
@@ -219,15 +202,15 @@
}
.clip-segment:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.7);
background-color: rgba(0, 0, 0, 0.5);
}
.clip-segment.selected .clip-segment-info {
background-color: rgba(37, 99, 235, 0.8);
background-color: rgba(59, 130, 246, 0.5);
}
.clip-segment.selected:hover .clip-segment-info {
background-color: rgba(37, 99, 235, 0.75);
background-color: rgba(59, 130, 246, 0.4);
}
.clip-segment-name {

View File

@@ -76,26 +76,10 @@
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);
@@ -104,11 +88,6 @@
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 {
@@ -130,7 +109,6 @@
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 3;
}
.video-player-container:hover .play-pause-indicator {
@@ -209,7 +187,6 @@
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 {

View File

@@ -20,7 +20,6 @@ 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);
@@ -32,8 +31,6 @@ 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(() => {
@@ -68,9 +65,6 @@ 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() {
@@ -170,8 +164,6 @@ 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);
@@ -363,37 +355,6 @@ 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;
@@ -408,11 +369,17 @@ class CustomChaptersOverlay extends Component {
navigator.vibrate(30);
}
// Lock/unlock body scroll on mobile when overlay opens/closes
if (isHidden) {
this.lockBodyScroll();
} else {
this.unlockBodyScroll();
// Prevent body scroll on mobile when overlay is open
if (this.isMobile) {
if (isHidden) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
} else {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
}
try {
@@ -423,9 +390,7 @@ class CustomChaptersOverlay extends Component {
m.classList.remove('vjs-lock-showing');
m.style.display = 'none';
});
} catch {
// Ignore errors when closing menus
}
} catch (e) {}
}
updateCurrentChapter() {
@@ -441,6 +406,7 @@ 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;
@@ -497,7 +463,11 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile
this.unlockBodyScroll();
if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
}
}
@@ -509,7 +479,11 @@ class CustomChaptersOverlay extends Component {
if (el) el.classList.remove('chapters-open');
// Restore body scroll on mobile when disposing
this.unlockBodyScroll();
if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Clean up event listeners
if (this.handleResize) {

View File

@@ -25,7 +25,6 @@ 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);
@@ -42,8 +41,6 @@ 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(() => {
@@ -659,8 +656,6 @@ class CustomSettingsMenu extends Component {
if (btnEl) {
btnEl.classList.remove('settings-clicked');
}
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
};
closeButton.addEventListener('click', closeFunction);
@@ -947,37 +942,6 @@ 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');
@@ -990,7 +954,11 @@ class CustomSettingsMenu extends Component {
this.stopKeepingControlsVisible();
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
} else {
this.settingsOverlay.classList.add('show');
this.settingsOverlay.style.display = 'block';
@@ -1004,7 +972,11 @@ class CustomSettingsMenu extends Component {
}
// Prevent body scroll on mobile when overlay is open
this.lockBodyScroll();
if (this.isMobile) {
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
@@ -1030,9 +1002,6 @@ 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';
@@ -1103,7 +1072,11 @@ class CustomSettingsMenu extends Component {
}
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
}
}
@@ -1444,8 +1417,6 @@ class CustomSettingsMenu extends Component {
if (btnEl) {
btnEl.classList.remove('settings-clicked');
}
// Restore body scroll on mobile when closing
this.unlockBodyScroll();
}
}
@@ -1522,7 +1493,11 @@ class CustomSettingsMenu extends Component {
}
// Restore body scroll on mobile when disposing
this.unlockBodyScroll();
if (this.isMobile) {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
// Remove DOM elements
if (this.settingsOverlay) {

View File

@@ -26,12 +26,17 @@ 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]);
@@ -74,9 +79,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
if (!isOpen) return null;
// 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.
const hasStateChanged = selectedState !== initialState;
return (
<div className="publish-state-modal-overlay">
@@ -113,7 +116,7 @@ export const BulkActionPublishStateModal: React.FC<BulkActionPublishStateModalPr
<button
className="publish-state-btn publish-state-btn-submit"
onClick={handleSubmit}
disabled={isProcessing}
disabled={isProcessing || !hasStateChanged}
>
{isProcessing ? translateString('Processing...') : translateString('Submit')}
</button>

View File

@@ -21,16 +21,12 @@ 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(media_data.original_media_url, SiteContext._currentValue.url),
link: formatInnerLink(encodings_info[k][g].url, SiteContext._currentValue.url),
linkAttr: {
target: '_blank',
download: originalFilename,
download: media_data.title + '_' + k + '_' + g.toUpperCase(),
},
};
}
@@ -40,16 +36,12 @@ 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: originalFilename,
download: media_data.title,
},
};

View File

@@ -54,10 +54,6 @@ 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);
}
@@ -175,7 +171,7 @@ export default class ViewerInfoTitleBanner extends React.PureComponent {
.downloadLink ? (
<VideoMediaDownloadLink />
) : (
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
<OtherMediaDownloadLink link={this.downloadLink} title={this.props.title} />
)}
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />

View File

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

View File

@@ -1,19 +0,0 @@
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.

Before

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