From bc04aba0d7f5dc4cf37665234ebaec2134ca7735 Mon Sep 17 00:00:00 2001 From: klop51 Date: Sat, 16 Aug 2025 00:36:15 +0200 Subject: [PATCH] feat: Implement drag-and-drop functionality for media items in the timeline and media bin --- video_editor_pyqt6.py | 774 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 706 insertions(+), 68 deletions(-) diff --git a/video_editor_pyqt6.py b/video_editor_pyqt6.py index 2c60b89..a261864 100644 --- a/video_editor_pyqt6.py +++ b/video_editor_pyqt6.py @@ -57,13 +57,13 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import ( Qt, QUrl, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect, - QThread, pyqtSlot, QObject, QRunnable, QThreadPool, QMutex, QSize, QEvent + QThread, pyqtSlot, QObject, QRunnable, QThreadPool, QMutex, QSize, QEvent, QMimeData, QPoint ) from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimediaWidgets import QVideoWidget from PyQt6.QtGui import ( QPalette, QColor, QFont, QIcon, QKeySequence, QShortcut, QPixmap, QPainter, - QBrush, QPen, QPolygon, QAction, QCursor + QBrush, QPen, QPolygon, QAction, QCursor, QDrag ) # Try to import MoviePy, handle if not available @@ -124,16 +124,23 @@ class TimelineWidget(QWidget): def __init__(self): super().__init__() - self.duration = 0.0 + self.duration = 30.0 # Start with 30 seconds default duration self.current_time = 0.0 self.zoom_level = 1.0 self.track_height = 70 # Adjusted for 5 tracks to fit properly self.ruler_height = 30 # Increased ruler height + self.timeline_clips = [] # Store clips on timeline + self.selected_clip_index = None # Track selected clip + self.setAcceptDrops(True) # Enable drag and drop self.setup_ui() def setup_ui(self): """Setup timeline interface""" self.setMinimumHeight(400) # Much larger for proper track visibility + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) # Allow keyboard focus + self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, False) # Disable touch to avoid conflicts + self.setMouseTracking(False) # Ensure we only get mouse press events + print(f"🔧 Timeline widget setup: size policy, focus policy, mouse tracking configured") self.setStyleSheet(""" TimelineWidget { background-color: #1e1e1e; @@ -177,22 +184,107 @@ class TimelineWidget(QWidget): painter.setPen(QPen(QColor(track_colors[i]), 2)) painter.drawRect(content_rect) - # Draw sample content blocks (visual representation) - if self.duration > 0: - content_width = content_rect.width() - # Draw a content block spanning part of the timeline - block_width = min(200, content_width // 2) + # Draw actual timeline clips for this track + # Use effective timeline duration for consistent positioning + effective_duration = self.get_effective_timeline_duration() + content_width = content_rect.width() + pixels_per_second = content_width / effective_duration + + # Draw clips on this track + for clip_index, clip in enumerate(self.timeline_clips): + if clip['track'] == i: + # Calculate clip position and size + clip_x = content_rect.x() + (clip['start_time'] * pixels_per_second) + clip_width = max(60, clip['duration'] * pixels_per_second) # Minimum width for visibility + clip_rect = QRect(int(clip_x), content_rect.y() + 3, + int(clip_width), content_rect.height() - 6) + + # Determine if this clip is selected + is_selected = (self.selected_clip_index == clip_index) + + # Make clips more visible with brighter colors and gradients + if clip['type'] == 'video': + # Bright blue for video clips + clip_color = QColor("#4A90E2") if not is_selected else QColor("#00AA00") # Green when selected + border_color = QColor("#2E5C8A") if not is_selected else QColor("#00FF00") # Bright green border when selected + else: + # Bright red for audio clips + clip_color = QColor("#E74C3C") if not is_selected else QColor("#FF6600") # Orange when selected + border_color = QColor("#C0392B") if not is_selected else QColor("#FF8800") # Bright orange border when selected + + # Fill with clip color + painter.fillRect(clip_rect, clip_color) + + # Add a subtle gradient effect + gradient_rect = QRect(clip_rect.x(), clip_rect.y(), + clip_rect.width(), clip_rect.height() // 3) + lighter_color = clip_color.lighter(130) + painter.fillRect(gradient_rect, lighter_color) + + # Draw border - thicker for selected clips + border_width = 3 if is_selected else 2 + painter.setPen(QPen(border_color, border_width)) + painter.drawRect(clip_rect) + + # Draw inner highlight - brighter for selected clips + highlight_color = QColor("#ffffff") if not is_selected else QColor("#ffff00") + painter.setPen(QPen(highlight_color, 1)) + inner_rect = QRect(clip_rect.x() + 1, clip_rect.y() + 1, + clip_rect.width() - 2, clip_rect.height() - 2) + painter.drawRect(inner_rect) + + # Draw selection indicator for selected clips + if is_selected: + # Draw selection corners + corner_size = 8 + painter.setPen(QPen(QColor("#ffff00"), 2)) + painter.setBrush(QColor("#ffff00")) + + # Top-left corner + painter.drawRect(clip_rect.x() - 2, clip_rect.y() - 2, corner_size, corner_size) + # Top-right corner + painter.drawRect(clip_rect.x() + clip_rect.width() - corner_size + 2, clip_rect.y() - 2, corner_size, corner_size) + # Bottom-left corner + painter.drawRect(clip_rect.x() - 2, clip_rect.y() + clip_rect.height() - corner_size + 2, corner_size, corner_size) + # Bottom-right corner + painter.drawRect(clip_rect.x() + clip_rect.width() - corner_size + 2, clip_rect.y() + clip_rect.height() - corner_size + 2, corner_size, corner_size) + + # Draw clip filename with better contrast + painter.setFont(QFont("Arial", 9, QFont.Weight.Bold)) + text_color = QColor("#ffffff") if not is_selected else QColor("#000000") # Black text on selected clips + painter.setPen(QPen(text_color, 1)) + + # Add text shadow for better readability (only for non-selected clips) + if not is_selected: + shadow_rect = QRect(clip_rect.x() + 3, clip_rect.y() + 1, + clip_rect.width() - 6, clip_rect.height()) + painter.setPen(QPen(QColor("#000000"), 1)) + painter.drawText(shadow_rect, Qt.AlignmentFlag.AlignCenter, + clip['filename'][:12] + "..." if len(clip['filename']) > 12 else clip['filename']) + + # Draw actual text + text_rect = QRect(clip_rect.x() + 2, clip_rect.y(), + clip_rect.width() - 4, clip_rect.height()) + painter.setPen(QPen(text_color, 1)) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, + clip['filename'][:12] + "..." if len(clip['filename']) > 12 else clip['filename']) + + # If no clips on this track, draw sample content block for visual reference + if not any(clip['track'] == i for clip in self.timeline_clips): + block_width = min(200, content_width // 4) block_rect = QRect(content_rect.x() + 10, content_rect.y() + 5, block_width, content_rect.height() - 10) - # Fill with track color - painter.fillRect(block_rect, QColor(track_colors[i])) - painter.setPen(QPen(QColor("#ffffff"), 1)) + # Fill with track color (dimmed) + dim_color = QColor(track_colors[i]) + dim_color.setAlpha(80) + painter.fillRect(block_rect, dim_color) + painter.setPen(QPen(QColor("#666666"), 1)) painter.drawRect(block_rect) - # Draw track name on the content block - painter.setFont(QFont("Arial", 10, QFont.Weight.Bold)) - painter.setPen(QPen(QColor("#ffffff"), 1)) + # Draw track name on the sample block + painter.setFont(QFont("Arial", 9)) + painter.setPen(QPen(QColor("#999999"), 1)) text_rect = QRect(block_rect.x() + 5, block_rect.y(), block_rect.width() - 10, block_rect.height()) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, track_names[i]) @@ -208,57 +300,254 @@ class TimelineWidget(QWidget): painter.drawLine(0, ruler_bottom, self.width(), ruler_bottom) # Draw playhead on top of everything - if self.duration > 0: - playhead_x = 120 + ((self.current_time / self.duration) * (self.width() - 130)) - painter.setPen(QPen(QColor("#ff4444"), 3)) - painter.drawLine(int(playhead_x), ruler_bottom, int(playhead_x), self.height()) + effective_duration = self.get_effective_timeline_duration() + if effective_duration > 0: + # Use current video time directly since timeline now accommodates video duration + timeline_position = self.current_time + + playhead_x = 120 + ((timeline_position / effective_duration) * (self.width() - 130)) - # Draw playhead handle - handle_rect = QRect(int(playhead_x) - 8, ruler_bottom - 15, 16, 15) - painter.fillRect(handle_rect, QColor("#ff4444")) - painter.setPen(QPen(QColor("#ffffff"), 1)) - painter.drawRect(handle_rect) + # Only draw playhead if it's within the visible timeline area + if 120 <= playhead_x <= self.width() - 20: + painter.setPen(QPen(QColor("#ff4444"), 3)) + painter.drawLine(int(playhead_x), ruler_bottom, int(playhead_x), self.height()) + + # Draw playhead handle + handle_rect = QRect(int(playhead_x) - 8, ruler_bottom - 15, 16, 15) + painter.fillRect(handle_rect, QColor("#ff4444")) + painter.setPen(QPen(QColor("#ffffff"), 1)) + painter.drawRect(handle_rect) def draw_time_ruler(self, painter): - """Draw time ruler at the top""" + """Draw time ruler at the top using effective timeline duration with smart scaling""" painter.fillRect(0, 0, self.width(), self.ruler_height, QColor("#2d2d2d")) - if self.duration > 0: - # Calculate time intervals + effective_duration = self.get_effective_timeline_duration() + if effective_duration > 0: + # Calculate time intervals based on effective timeline content_area_start = 120 content_area_width = self.width() - 130 - pixels_per_second = content_area_width / self.duration + pixels_per_second = content_area_width / effective_duration painter.setPen(QPen(QColor("#cccccc"), 1)) painter.setFont(QFont("Arial", 9)) - # Draw second markers - for second in range(int(self.duration) + 1): - x = content_area_start + (second * pixels_per_second) + # Smart interval calculation based on timeline duration and available space + if effective_duration <= 300: # Under 5 minute + major_interval = 10 # Every 10 seconds + minor_interval = 1 # Minor marks every 1 seconds + elif effective_duration <= 1800: # Under 30 minutes + major_interval = 120 # Every 2 minutes + minor_interval = 60 # Minor marks every minute + else: # Over 30 minutes + major_interval = 300 # Every 5 minutes + minor_interval = 60 # Minor marks every 1 minutes + + # Draw major time markers with labels + current_time = 0 + while current_time <= effective_duration: + x = content_area_start + (current_time * pixels_per_second) if x <= self.width() - 10: - painter.drawLine(int(x), self.ruler_height - 8, int(x), self.ruler_height) + # Draw major marker line + painter.setPen(QPen(QColor("#cccccc"), 2)) + painter.drawLine(int(x), 0, int(x), self.ruler_height - 5) - # Draw time labels every 5 seconds - if second % 5 == 0: - painter.drawText(int(x) + 2, self.ruler_height - 12, f"{second}s") + # Format and draw time label + if current_time >= 60: + minutes = int(current_time // 60) + seconds = int(current_time % 60) + time_text = f"{minutes}:{seconds:02d}" + else: + time_text = f"{int(current_time)}s" + + painter.setPen(QPen(QColor("#ffffff"), 1)) + painter.drawText(int(x) + 2, self.ruler_height - 12, time_text) + + current_time += major_interval + + # Draw minor time markers (no labels) + if minor_interval != major_interval: + painter.setPen(QPen(QColor("#888888"), 1)) + current_time = 0 + while current_time <= effective_duration: + if current_time % major_interval != 0: # Skip major intervals + x = content_area_start + (current_time * pixels_per_second) + if x <= self.width() - 10: + painter.drawLine(int(x), self.ruler_height - 12, int(x), self.ruler_height - 5) + current_time += minor_interval # Draw ruler border painter.setPen(QPen(QColor("#404040"), 1)) painter.drawRect(0, 0, self.width(), self.ruler_height) def mousePressEvent(self, event): - """Handle timeline clicking for seeking""" - if event.button() == Qt.MouseButton.LeftButton and self.duration > 0: - # Only allow seeking in the content area (not on track labels) + """Handle timeline clicking for seeking and clip selection""" + print(f"🖱️ Timeline mousePressEvent called: button={event.button()}, pos=({event.position().x():.1f}, {event.position().y():.1f})") + + if event.button() == Qt.MouseButton.LeftButton: + # Only allow operations in the content area (not on track labels) content_area_start = 120 - if event.position().x() >= content_area_start: - # Calculate time from click position relative to content area - content_width = self.width() - 130 - click_x = event.position().x() - content_area_start - click_time = (click_x / content_width) * self.duration - self.current_time = max(0, min(click_time, self.duration)) - self.positionChanged.emit(self.current_time) - self.update() + click_x = event.position().x() + click_y = event.position().y() + + if click_x >= content_area_start: + # Check if CTRL is pressed for clip selection + ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier + + if ctrl_pressed: + # CTRL+Click: Select/deselect clip without moving playhead + clicked_clip_index = self.get_clip_at_position(click_x, click_y) + + if clicked_clip_index is not None: + # CTRL+Click on clip: select it + self.selected_clip_index = clicked_clip_index + clip = self.timeline_clips[clicked_clip_index] + print(f"🎯 CTRL+Click: Selected clip: {clip['filename']} on track {clip['track']}") + self.update() + else: + # CTRL+Click on empty area: just deselect + if self.selected_clip_index is not None: + print("🎯 CTRL+Click: Deselected all clips (playhead stays)") + self.selected_clip_index = None + self.update() + else: + print("🎯 CTRL+Click: No clips to deselect (playhead stays)") + # Do NOT move playhead when CTRL is pressed + else: + # Regular click without CTRL + clicked_clip_index = self.get_clip_at_position(click_x, click_y) + + if clicked_clip_index is not None: + # Click on clip: select it (no playhead movement) + self.selected_clip_index = clicked_clip_index + clip = self.timeline_clips[clicked_clip_index] + print(f"🎯 Selected clip: {clip['filename']} on track {clip['track']}") + self.update() + else: + # Click on empty area: deselect clips and move playhead + + # First handle deselection + if self.selected_clip_index is not None: + print("🎯 Deselected all clips") + self.selected_clip_index = None + + # Then move playhead using effective timeline duration + effective_duration = self.get_effective_timeline_duration() + if effective_duration > 0: + content_width = self.width() - 130 + click_x_relative = click_x - content_area_start + click_time = (click_x_relative / content_width) * effective_duration + self.current_time = max(0, min(click_time, self.duration)) + print(f"⏭️ Playhead moved to {self.current_time:.2f}s") + self.positionChanged.emit(self.current_time) + + self.update() + else: + print(f"🖱️ Click outside content area (x={click_x:.1f}, content_start={content_area_start})") + else: + print(f"🖱️ Non-left button click: {event.button()}") + + def get_effective_timeline_duration(self): + """Calculate the effective timeline duration for display and interaction + + This uses a dynamic timeline approach that expands to accommodate + both clips and the loaded video duration. + """ + # Start with a minimum timeline duration + min_duration = 30.0 + + # Find the latest clip end time + latest_clip_time = 0 + if self.timeline_clips: + for clip in self.timeline_clips: + clip_end = clip['start_time'] + clip['duration'] + latest_clip_time = max(latest_clip_time, clip_end) + + # Consider the video duration if a video is loaded + video_duration = getattr(self, 'duration', 0) + + # Use the maximum of: + # 1. Minimum duration (30s) + # 2. Latest clip time + padding + # 3. Current video duration (if reasonable) + timeline_duration = max( + min_duration, + latest_clip_time + 10.0, # Add 10s padding after clips + min(video_duration, 3600) # Cap at 1 hour for UI performance + ) + + return timeline_duration + + def get_clip_at_position(self, click_x, click_y): + """Get clip index at the clicked position using effective timeline scaling""" + if not self.timeline_clips: + return None + + content_area_start = 120 + content_width = self.width() - 130 + + # Use effective timeline duration instead of video duration + effective_duration = self.get_effective_timeline_duration() + pixels_per_second = content_width / effective_duration + + # Convert click position to time + click_x_relative = click_x - content_area_start + clicked_time = click_x_relative / pixels_per_second + + # Get track from Y position + track_index = self.get_track_from_y(click_y) + if track_index < 0: + return None + + # Find clip at this position + for i, clip in enumerate(self.timeline_clips): + if clip['track'] == track_index: + clip_start = clip['start_time'] + clip_end = clip_start + clip['duration'] + + if clip_start <= clicked_time <= clip_end: + return i + + return None + + def select_clip_at_position(self, click_x, click_y): + """Select clip at position without affecting playhead""" + clicked_clip_index = self.get_clip_at_position(click_x, click_y) + + if clicked_clip_index is not None: + self.selected_clip_index = clicked_clip_index + clip = self.timeline_clips[clicked_clip_index] + print(f"🎯 Selected clip: {clip['filename']} on track {clip['track']}") + else: + self.selected_clip_index = None + print("🎯 Deselected all clips") + + self.update() + + def keyPressEvent(self, event): + """Handle keyboard events for clip operations""" + if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: + # Delete selected clip + self.delete_selected_clip() + else: + super().keyPressEvent(event) + + def delete_selected_clip(self): + """Delete the currently selected clip""" + if (self.selected_clip_index is not None and + 0 <= self.selected_clip_index < len(self.timeline_clips)): + + deleted_clip = self.timeline_clips[self.selected_clip_index] + del self.timeline_clips[self.selected_clip_index] + + print(f"🗑️ Deleted clip: {deleted_clip['filename']}") + print(f"📊 Timeline now has {len(self.timeline_clips)} clip(s)") + + # Clear selection + self.selected_clip_index = None + + # Update display + self.update() def set_duration(self, duration): """Set timeline duration""" @@ -269,6 +558,122 @@ class TimelineWidget(QWidget): """Set current playhead position""" self.current_time = position self.update() + + def dragEnterEvent(self, event): + """Handle drag enter event - check if it's a media item""" + if event.mimeData().hasFormat("application/x-media-item"): + event.acceptProposedAction() + # Visual feedback + self.setStyleSheet(""" + TimelineWidget { + background-color: #2a3a2a; + border: 2px dashed #00aa00; + } + """) + else: + event.ignore() + + def dragMoveEvent(self, event): + """Handle drag move event""" + if event.mimeData().hasFormat("application/x-media-item"): + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event): + """Handle drag leave event - restore normal appearance""" + self.setStyleSheet(""" + TimelineWidget { + background-color: #1e1e1e; + border: 1px solid #404040; + } + """) + + def dropEvent(self, event): + """Handle drop event - add media to timeline""" + # Restore normal appearance + self.setStyleSheet(""" + TimelineWidget { + background-color: #1e1e1e; + border: 1px solid #404040; + } + """) + + if event.mimeData().hasFormat("application/x-media-item"): + # Get file path from mime data + file_path = event.mimeData().data("application/x-media-item").data().decode() + + # Calculate drop position + drop_x = event.position().x() + drop_y = event.position().y() + + # Check if dropped in valid area (content area) + content_area_start = 120 + if drop_x >= content_area_start: + # If timeline has no duration, set a default duration of 30 seconds + if self.duration <= 0: + self.duration = 30.0 + + # Calculate time position + content_width = self.width() - 130 + click_x = drop_x - content_area_start + time_position = (click_x / content_width) * self.duration + + # Determine which track based on Y position + track_index = self.get_track_from_y(drop_y) + + if track_index >= 0: + # Create clip info + clip_info = { + 'file_path': file_path, + 'filename': os.path.basename(file_path), + 'start_time': max(0, time_position), + 'duration': 3.0, # Default duration + 'track': track_index, + 'type': 'video' if self.is_video_file(file_path) else 'audio' + } + + # Add to timeline clips + self.timeline_clips.append(clip_info) + self.update() + + # Visual and audio feedback for successful drop + print(f"✅ Added {os.path.basename(file_path)} to track {track_index} at {time_position:.1f}s") + print(f"📊 Timeline now has {len(self.timeline_clips)} clip(s)") + + # Trigger a repaint to immediately show the new clip + QApplication.processEvents() + + event.acceptProposedAction() + else: + print("❌ Invalid track area") + event.ignore() + else: + print("❌ Dropped outside content area") + event.ignore() + else: + event.ignore() + + def get_track_from_y(self, y_pos): + """Get track index from Y position""" + ruler_bottom = self.ruler_height + + if y_pos < ruler_bottom: + return -1 # Dropped on ruler + + track_y = y_pos - ruler_bottom + track_index = int(track_y // self.track_height) + + # Validate track index (5 tracks: 0-4) + if 0 <= track_index < 5: + return track_index + return -1 + + def is_video_file(self, file_path): + """Check if file is a video file""" + video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'] + ext = os.path.splitext(file_path)[1].lower() + return ext in video_extensions class EffectsPanel(QWidget): """Professional effects panel with time-based controls""" @@ -448,15 +853,180 @@ class EffectsPanel(QWidget): } self.effectApplied.emit('text', params) +class DraggableMediaItem(QPushButton): + """Draggable media item that can be dragged to timeline""" + + mediaSelected = pyqtSignal(str) # file_path + + def __init__(self, file_path, parent=None): + super().__init__(f"🎬 {os.path.basename(file_path)}", parent) + self.file_path = file_path + self.drag_start_position = QPoint() + self.dragging = False + + # Store media info for drag operations + self.media_info = { + 'path': file_path, + 'filename': os.path.basename(file_path), + 'type': 'video' if self.is_video_file(file_path) else 'audio' + } + + self.setStyleSheet(""" + QPushButton { + text-align: left; + padding: 4px 6px; + margin: 0px; + border: 1px solid #404040; + border-radius: 3px; + background-color: #2d2d2d; + color: #ffffff; + min-height: 18px; + max-height: 26px; + font-size: 12px; + } + QPushButton:hover { + background-color: #404040; + border: 1px solid #00aaff; + } + """) + + self.clicked.connect(self.select_media) + + def is_video_file(self, file_path): + """Check if file is a video file""" + video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'] + ext = os.path.splitext(file_path)[1].lower() + return ext in video_extensions + + def select_media(self): + """Emit signal for media selection""" + self.mediaSelected.emit(self.file_path) + print(f"🎬 Selected: {os.path.basename(self.file_path)}") + + def mousePressEvent(self, event): + """Handle mouse press - start potential drag operation""" + if event.button() == Qt.MouseButton.LeftButton: + self.drag_start_position = event.pos() + # Visual feedback for drag start + self.setStyleSheet(""" + QPushButton { + text-align: left; + padding: 4px 6px; + margin: 0px; + border: 2px solid #00aaff; + border-radius: 3px; + background-color: #003366; + color: #ffffff; + min-height: 18px; + max-height: 26px; + font-size: 12px; + } + """) + print(f"🎬 Started dragging: {self.media_info['filename']}") + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + """Handle mouse move - perform drag operation""" + if not (event.buttons() & Qt.MouseButton.LeftButton): + return + + if not self.dragging and (event.pos() - self.drag_start_position).manhattanLength() < QApplication.startDragDistance(): + return + + if not self.dragging: + self.dragging = True + # Start drag operation + drag = QDrag(self) + mimeData = QMimeData() + + # Store media info in mime data + mimeData.setText(f"media_drag:{self.file_path}") + mimeData.setData("application/x-media-item", self.file_path.encode()) + + drag.setMimeData(mimeData) + + # Create a more visible drag pixmap + pixmap = QPixmap(150, 40) + pixmap.fill(QColor(0, 170, 255, 200)) + + painter = QPainter(pixmap) + painter.setPen(QPen(QColor("#ffffff"), 2)) + painter.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + painter.drawText(10, 25, f"🎬 {os.path.basename(self.file_path)[:12]}...") + painter.end() + + drag.setPixmap(pixmap) + drag.setHotSpot(QPoint(75, 20)) + + # Execute drag + dropAction = drag.exec(Qt.DropAction.CopyAction) + + # Reset dragging state + self.dragging = False + self.reset_appearance() + + def mouseReleaseEvent(self, event): + """Handle mouse release - end drag operation""" + self.reset_appearance() + super().mouseReleaseEvent(event) + + def reset_appearance(self): + """Reset button appearance to normal""" + self.setStyleSheet(""" + QPushButton { + text-align: left; + padding: 4px 6px; + margin: 0px; + border: 1px solid #404040; + border-radius: 3px; + background-color: #2d2d2d; + color: #ffffff; + min-height: 18px; + max-height: 26px; + font-size: 12px; + } + QPushButton:hover { + background-color: #404040; + border: 1px solid #00aaff; + } + """) + +class DropScrollArea(QScrollArea): + """Custom scroll area that handles drag and drop events""" + + def __init__(self, media_bin, parent=None): + super().__init__(parent) + self.media_bin = media_bin + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + """Forward drag enter event to media bin""" + self.media_bin.dragEnterEvent(event) + + def dragMoveEvent(self, event): + """Forward drag move event to media bin""" + self.media_bin.dragMoveEvent(event) + + def dragLeaveEvent(self, event): + """Forward drag leave event to media bin""" + self.media_bin.dragLeaveEvent(event) + + def dropEvent(self, event): + """Forward drop event to media bin""" + self.media_bin.dropEvent(event) + class MediaBin(QWidget): - """Professional media bin for file management""" + """Professional media bin for file management with drag and drop support""" mediaSelected = pyqtSignal(str) # file_path def __init__(self): super().__init__() self.media_files = [] + # Enable drag and drop from Windows Explorer + self.setAcceptDrops(True) self.setup_ui() + self.auto_load_media() def setup_ui(self): """Setup media bin interface""" @@ -503,9 +1073,11 @@ class MediaBin(QWidget): # Add stretch at the end to push items to the top self.media_list_layout.addStretch() - scroll_area = QScrollArea() + scroll_area = DropScrollArea(self) scroll_area.setWidget(self.media_list) scroll_area.setWidgetResizable(True) + # Store reference to handle events + self.scroll_area = scroll_area scroll_area.setStyleSheet(""" QScrollArea { border: 1px solid #404040; @@ -546,36 +1118,102 @@ class MediaBin(QWidget): if file_path not in self.media_files: self.media_files.append(file_path) - # Create media item widget - media_item = QPushButton(f"🎬 {os.path.basename(file_path)}") - media_item.setStyleSheet(""" - QPushButton { - text-align: left; - padding: 4px 6px; - margin: 0px; - border: 1px solid #404040; - border-radius: 3px; - background-color: #2d2d2d; - color: #ffffff; - min-height: 18px; - max-height: 26px; - font-size: 12px; - } - QPushButton:hover { - background-color: #404040; - border: 1px solid #00aaff; - } - """) - media_item.clicked.connect(lambda: self.select_media(file_path)) + # Create draggable media item widget + media_item = DraggableMediaItem(file_path) + media_item.mediaSelected.connect(self.handle_media_selected) # Insert before the stretch to keep items at the top self.media_list_layout.insertWidget(self.media_list_layout.count() - 1, media_item) print(f"📁 Added to media bin: {os.path.basename(file_path)}") + def handle_media_selected(self, file_path): + """Handle media selection from draggable item""" + self.mediaSelected.emit(file_path) + def select_media(self, file_path): """Select media file""" self.mediaSelected.emit(file_path) print(f"🎬 Selected: {os.path.basename(file_path)}") + + def dragEnterEvent(self, event): + """Handle drag enter event - check if files are being dragged""" + if event.mimeData().hasUrls(): + # Check if any of the URLs are video/audio files + for url in event.mimeData().urls(): + if url.isLocalFile(): + file_path = url.toLocalFile() + ext = os.path.splitext(file_path)[1].lower() + video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', + '.mp3', '.wav', '.aac', '.ogg', '.m4a'] + if ext in video_extensions: + event.acceptProposedAction() + # Visual feedback - change scroll area background color + self.scroll_area.setStyleSheet(""" + QScrollArea { + border: 2px dashed #00aa00; + border-radius: 4px; + background-color: #2a4a2a; + padding: 2px; + } + QScrollBar:vertical { + width: 12px; + background-color: #2d2d2d; + } + """) + return + event.ignore() + + def dragMoveEvent(self, event): + """Handle drag move event""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dragLeaveEvent(self, event): + """Handle drag leave event - restore normal appearance""" + self.scroll_area.setStyleSheet(""" + QScrollArea { + border: 1px solid #404040; + border-radius: 4px; + background-color: #1e1e1e; + padding: 2px; + } + QScrollBar:vertical { + width: 12px; + background-color: #2d2d2d; + } + """) + + def dropEvent(self, event): + """Handle drop event - add dropped files to media bin""" + # Restore normal appearance + self.scroll_area.setStyleSheet(""" + QScrollArea { + border: 1px solid #404040; + border-radius: 4px; + background-color: #1e1e1e; + padding: 2px; + } + QScrollBar:vertical { + width: 12px; + background-color: #2d2d2d; + } + """) + + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + if url.isLocalFile(): + file_path = url.toLocalFile() + ext = os.path.splitext(file_path)[1].lower() + video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', + '.mp3', '.wav', '.aac', '.ogg', '.m4a'] + if ext in video_extensions: + self.add_media_to_list(file_path) + print(f"🎯 Dropped file: {os.path.basename(file_path)}") + event.acceptProposedAction() + else: + event.ignore() class ProfessionalVideoEditor(QMainWindow): """Professional video editor with PyQt6"""