feat: Implement drag-and-drop functionality for media items in the timeline and media bin
This commit is contained in:
parent
82082911b3
commit
bc04aba0d7
@ -57,13 +57,13 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
Qt, QUrl, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect,
|
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.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
from PyQt6.QtMultimediaWidgets import QVideoWidget
|
from PyQt6.QtMultimediaWidgets import QVideoWidget
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
QPalette, QColor, QFont, QIcon, QKeySequence, QShortcut, QPixmap, QPainter,
|
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
|
# Try to import MoviePy, handle if not available
|
||||||
@ -124,16 +124,23 @@ class TimelineWidget(QWidget):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.duration = 0.0
|
self.duration = 30.0 # Start with 30 seconds default duration
|
||||||
self.current_time = 0.0
|
self.current_time = 0.0
|
||||||
self.zoom_level = 1.0
|
self.zoom_level = 1.0
|
||||||
self.track_height = 70 # Adjusted for 5 tracks to fit properly
|
self.track_height = 70 # Adjusted for 5 tracks to fit properly
|
||||||
self.ruler_height = 30 # Increased ruler height
|
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()
|
self.setup_ui()
|
||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
"""Setup timeline interface"""
|
"""Setup timeline interface"""
|
||||||
self.setMinimumHeight(400) # Much larger for proper track visibility
|
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("""
|
self.setStyleSheet("""
|
||||||
TimelineWidget {
|
TimelineWidget {
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
@ -177,22 +184,107 @@ class TimelineWidget(QWidget):
|
|||||||
painter.setPen(QPen(QColor(track_colors[i]), 2))
|
painter.setPen(QPen(QColor(track_colors[i]), 2))
|
||||||
painter.drawRect(content_rect)
|
painter.drawRect(content_rect)
|
||||||
|
|
||||||
# Draw sample content blocks (visual representation)
|
# Draw actual timeline clips for this track
|
||||||
if self.duration > 0:
|
# Use effective timeline duration for consistent positioning
|
||||||
content_width = content_rect.width()
|
effective_duration = self.get_effective_timeline_duration()
|
||||||
# Draw a content block spanning part of the timeline
|
content_width = content_rect.width()
|
||||||
block_width = min(200, content_width // 2)
|
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_rect = QRect(content_rect.x() + 10, content_rect.y() + 5,
|
||||||
block_width, content_rect.height() - 10)
|
block_width, content_rect.height() - 10)
|
||||||
|
|
||||||
# Fill with track color
|
# Fill with track color (dimmed)
|
||||||
painter.fillRect(block_rect, QColor(track_colors[i]))
|
dim_color = QColor(track_colors[i])
|
||||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
dim_color.setAlpha(80)
|
||||||
|
painter.fillRect(block_rect, dim_color)
|
||||||
|
painter.setPen(QPen(QColor("#666666"), 1))
|
||||||
painter.drawRect(block_rect)
|
painter.drawRect(block_rect)
|
||||||
|
|
||||||
# Draw track name on the content block
|
# Draw track name on the sample block
|
||||||
painter.setFont(QFont("Arial", 10, QFont.Weight.Bold))
|
painter.setFont(QFont("Arial", 9))
|
||||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
painter.setPen(QPen(QColor("#999999"), 1))
|
||||||
text_rect = QRect(block_rect.x() + 5, block_rect.y(),
|
text_rect = QRect(block_rect.x() + 5, block_rect.y(),
|
||||||
block_rect.width() - 10, block_rect.height())
|
block_rect.width() - 10, block_rect.height())
|
||||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, track_names[i])
|
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)
|
painter.drawLine(0, ruler_bottom, self.width(), ruler_bottom)
|
||||||
|
|
||||||
# Draw playhead on top of everything
|
# Draw playhead on top of everything
|
||||||
if self.duration > 0:
|
effective_duration = self.get_effective_timeline_duration()
|
||||||
playhead_x = 120 + ((self.current_time / self.duration) * (self.width() - 130))
|
if effective_duration > 0:
|
||||||
painter.setPen(QPen(QColor("#ff4444"), 3))
|
# Use current video time directly since timeline now accommodates video duration
|
||||||
painter.drawLine(int(playhead_x), ruler_bottom, int(playhead_x), self.height())
|
timeline_position = self.current_time
|
||||||
|
|
||||||
# Draw playhead handle
|
playhead_x = 120 + ((timeline_position / effective_duration) * (self.width() - 130))
|
||||||
handle_rect = QRect(int(playhead_x) - 8, ruler_bottom - 15, 16, 15)
|
|
||||||
painter.fillRect(handle_rect, QColor("#ff4444"))
|
# Only draw playhead if it's within the visible timeline area
|
||||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
if 120 <= playhead_x <= self.width() - 20:
|
||||||
painter.drawRect(handle_rect)
|
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):
|
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"))
|
painter.fillRect(0, 0, self.width(), self.ruler_height, QColor("#2d2d2d"))
|
||||||
|
|
||||||
if self.duration > 0:
|
effective_duration = self.get_effective_timeline_duration()
|
||||||
# Calculate time intervals
|
if effective_duration > 0:
|
||||||
|
# Calculate time intervals based on effective timeline
|
||||||
content_area_start = 120
|
content_area_start = 120
|
||||||
content_area_width = self.width() - 130
|
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.setPen(QPen(QColor("#cccccc"), 1))
|
||||||
painter.setFont(QFont("Arial", 9))
|
painter.setFont(QFont("Arial", 9))
|
||||||
|
|
||||||
# Draw second markers
|
# Smart interval calculation based on timeline duration and available space
|
||||||
for second in range(int(self.duration) + 1):
|
if effective_duration <= 300: # Under 5 minute
|
||||||
x = content_area_start + (second * pixels_per_second)
|
major_interval = 10 # Every 10 seconds
|
||||||
if x <= self.width() - 10:
|
minor_interval = 1 # Minor marks every 1 seconds
|
||||||
painter.drawLine(int(x), self.ruler_height - 8, int(x), self.ruler_height)
|
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 time labels every 5 seconds
|
# Draw major time markers with labels
|
||||||
if second % 5 == 0:
|
current_time = 0
|
||||||
painter.drawText(int(x) + 2, self.ruler_height - 12, f"{second}s")
|
while current_time <= effective_duration:
|
||||||
|
x = content_area_start + (current_time * pixels_per_second)
|
||||||
|
if x <= self.width() - 10:
|
||||||
|
# Draw major marker line
|
||||||
|
painter.setPen(QPen(QColor("#cccccc"), 2))
|
||||||
|
painter.drawLine(int(x), 0, int(x), self.ruler_height - 5)
|
||||||
|
|
||||||
|
# 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
|
# Draw ruler border
|
||||||
painter.setPen(QPen(QColor("#404040"), 1))
|
painter.setPen(QPen(QColor("#404040"), 1))
|
||||||
painter.drawRect(0, 0, self.width(), self.ruler_height)
|
painter.drawRect(0, 0, self.width(), self.ruler_height)
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
"""Handle timeline clicking for seeking"""
|
"""Handle timeline clicking for seeking and clip selection"""
|
||||||
if event.button() == Qt.MouseButton.LeftButton and self.duration > 0:
|
print(f"🖱️ Timeline mousePressEvent called: button={event.button()}, pos=({event.position().x():.1f}, {event.position().y():.1f})")
|
||||||
# Only allow seeking in the content area (not on track labels)
|
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
# Only allow operations in the content area (not on track labels)
|
||||||
content_area_start = 120
|
content_area_start = 120
|
||||||
if event.position().x() >= content_area_start:
|
click_x = event.position().x()
|
||||||
# Calculate time from click position relative to content area
|
click_y = event.position().y()
|
||||||
content_width = self.width() - 130
|
|
||||||
click_x = event.position().x() - content_area_start
|
if click_x >= content_area_start:
|
||||||
click_time = (click_x / content_width) * self.duration
|
# Check if CTRL is pressed for clip selection
|
||||||
self.current_time = max(0, min(click_time, self.duration))
|
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
||||||
self.positionChanged.emit(self.current_time)
|
|
||||||
self.update()
|
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):
|
def set_duration(self, duration):
|
||||||
"""Set timeline duration"""
|
"""Set timeline duration"""
|
||||||
@ -270,6 +559,122 @@ class TimelineWidget(QWidget):
|
|||||||
self.current_time = position
|
self.current_time = position
|
||||||
self.update()
|
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):
|
class EffectsPanel(QWidget):
|
||||||
"""Professional effects panel with time-based controls"""
|
"""Professional effects panel with time-based controls"""
|
||||||
|
|
||||||
@ -448,15 +853,180 @@ class EffectsPanel(QWidget):
|
|||||||
}
|
}
|
||||||
self.effectApplied.emit('text', params)
|
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):
|
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
|
mediaSelected = pyqtSignal(str) # file_path
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.media_files = []
|
self.media_files = []
|
||||||
|
# Enable drag and drop from Windows Explorer
|
||||||
|
self.setAcceptDrops(True)
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
|
self.auto_load_media()
|
||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
"""Setup media bin interface"""
|
"""Setup media bin interface"""
|
||||||
@ -503,9 +1073,11 @@ class MediaBin(QWidget):
|
|||||||
# Add stretch at the end to push items to the top
|
# Add stretch at the end to push items to the top
|
||||||
self.media_list_layout.addStretch()
|
self.media_list_layout.addStretch()
|
||||||
|
|
||||||
scroll_area = QScrollArea()
|
scroll_area = DropScrollArea(self)
|
||||||
scroll_area.setWidget(self.media_list)
|
scroll_area.setWidget(self.media_list)
|
||||||
scroll_area.setWidgetResizable(True)
|
scroll_area.setWidgetResizable(True)
|
||||||
|
# Store reference to handle events
|
||||||
|
self.scroll_area = scroll_area
|
||||||
scroll_area.setStyleSheet("""
|
scroll_area.setStyleSheet("""
|
||||||
QScrollArea {
|
QScrollArea {
|
||||||
border: 1px solid #404040;
|
border: 1px solid #404040;
|
||||||
@ -546,37 +1118,103 @@ class MediaBin(QWidget):
|
|||||||
if file_path not in self.media_files:
|
if file_path not in self.media_files:
|
||||||
self.media_files.append(file_path)
|
self.media_files.append(file_path)
|
||||||
|
|
||||||
# Create media item widget
|
# Create draggable media item widget
|
||||||
media_item = QPushButton(f"🎬 {os.path.basename(file_path)}")
|
media_item = DraggableMediaItem(file_path)
|
||||||
media_item.setStyleSheet("""
|
media_item.mediaSelected.connect(self.handle_media_selected)
|
||||||
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))
|
|
||||||
# Insert before the stretch to keep items at the top
|
# Insert before the stretch to keep items at the top
|
||||||
self.media_list_layout.insertWidget(self.media_list_layout.count() - 1, media_item)
|
self.media_list_layout.insertWidget(self.media_list_layout.count() - 1, media_item)
|
||||||
|
|
||||||
print(f"📁 Added to media bin: {os.path.basename(file_path)}")
|
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):
|
def select_media(self, file_path):
|
||||||
"""Select media file"""
|
"""Select media file"""
|
||||||
self.mediaSelected.emit(file_path)
|
self.mediaSelected.emit(file_path)
|
||||||
print(f"🎬 Selected: {os.path.basename(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):
|
class ProfessionalVideoEditor(QMainWindow):
|
||||||
"""Professional video editor with PyQt6"""
|
"""Professional video editor with PyQt6"""
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user