1141 lines
37 KiB
Python
1141 lines
37 KiB
Python
# Standard library imports
|
|
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
|
|
|
# PyQt imports
|
|
from PyQt6.QtCore import (
|
|
QAbstractItemModel,
|
|
QEvent,
|
|
QItemSelection,
|
|
QModelIndex,
|
|
QObject,
|
|
QPoint,
|
|
QSize,
|
|
Qt,
|
|
QTimer,
|
|
)
|
|
from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument
|
|
from PyQt6.QtWidgets import (
|
|
QAbstractItemDelegate,
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QDoubleSpinBox,
|
|
QFrame,
|
|
QMenu,
|
|
QMessageBox,
|
|
QStyledItemDelegate,
|
|
QStyleOptionViewItem,
|
|
QTableView,
|
|
QTableWidgetItem,
|
|
QTextEdit,
|
|
QWidget,
|
|
)
|
|
|
|
# Third party imports
|
|
# import line_profiler
|
|
|
|
# App imports
|
|
from audacity_controller import AudacityController
|
|
from classes import (
|
|
ApplicationError,
|
|
Col,
|
|
MusicMusterSignals,
|
|
PlaylistStyle,
|
|
PlayTrack,
|
|
TrackInfo
|
|
)
|
|
from config import Config
|
|
from dialogs import TrackInsertDialog
|
|
from helpers import (
|
|
ask_yes_no,
|
|
ms_to_mmss,
|
|
show_OK,
|
|
show_warning,
|
|
)
|
|
from log import log, log_call
|
|
from playlistrow import TrackSequence
|
|
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
|
import ds
|
|
|
|
if TYPE_CHECKING:
|
|
from musicmuster import Window
|
|
|
|
|
|
class PlaylistDelegate(QStyledItemDelegate):
|
|
"""
|
|
- closes the edit on control-return
|
|
- checks with user before abandoning edit on Escape
|
|
- positions cursor where double-click occurs
|
|
- expands edit box and parent table row as text is added
|
|
|
|
Parts inspired by https://stackoverflow.com/questions/69113867/
|
|
make-row-of-qtableview-expand-as-editor-grows-in-height
|
|
"""
|
|
|
|
class EditorDocument(QTextDocument):
|
|
def __init__(self, parent):
|
|
super().__init__(parent)
|
|
self.setDocumentMargin(0)
|
|
self.contentsChange.connect(self.contents_change)
|
|
self.height = None
|
|
parent.setDocument(self)
|
|
|
|
def contents_change(self, position, chars_removed, chars_added):
|
|
def resize_func():
|
|
if self.size().height() != self.height:
|
|
doc_size = self.size()
|
|
self.parent().resize(int(doc_size.width()), int(doc_size.height()))
|
|
|
|
QTimer.singleShot(0, resize_func)
|
|
|
|
def __init__(self, parent: QWidget, base_model: PlaylistModel) -> None:
|
|
super().__init__(parent)
|
|
self.base_model = base_model
|
|
self.signals = MusicMusterSignals()
|
|
self.click_position = None
|
|
self.current_editor: Optional[Any] = None
|
|
|
|
def createEditor(
|
|
self,
|
|
parent: Optional[QWidget],
|
|
option: QStyleOptionViewItem,
|
|
index: QModelIndex,
|
|
) -> Optional[QDoubleSpinBox | QTextEdit]:
|
|
"""
|
|
Intercept createEditor call and make row just a little bit taller
|
|
"""
|
|
|
|
editor: QDoubleSpinBox | QTextEdit
|
|
|
|
class Editor(QTextEdit):
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
parent.parent().resizeRowToContents(index.row())
|
|
|
|
self.signals = MusicMusterSignals()
|
|
self.signals.enable_escape_signal.emit(False)
|
|
|
|
if self.current_editor:
|
|
editor = self.current_editor
|
|
else:
|
|
if index.column() == Col.INTRO.value:
|
|
editor = QDoubleSpinBox(parent)
|
|
editor.setDecimals(1)
|
|
editor.setSingleStep(0.1)
|
|
return editor
|
|
elif isinstance(index.data(), str):
|
|
editor = Editor(parent)
|
|
editor.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
editor.setFrameShape(QFrame.Shape.NoFrame)
|
|
self.current_editor = editor
|
|
PlaylistDelegate.EditorDocument(editor)
|
|
return editor
|
|
|
|
def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None:
|
|
"""
|
|
Intercept editor destroyment
|
|
"""
|
|
|
|
super().destroyEditor(editor, index)
|
|
self.current_editor = None
|
|
# Funky mypy dancing:
|
|
parent = self.parent()
|
|
if parent and hasattr(parent, "resizeRowToContents"):
|
|
parent.resizeRowToContents(index.row())
|
|
self.signals.enable_escape_signal.emit(True)
|
|
|
|
def editorEvent(
|
|
self,
|
|
event: Optional[QEvent],
|
|
model: Optional[QAbstractItemModel],
|
|
option: QStyleOptionViewItem,
|
|
index: QModelIndex,
|
|
) -> bool:
|
|
"""Capture mouse click position."""
|
|
|
|
if event and event.type() == QEvent.Type.MouseButtonPress:
|
|
if hasattr(event, "pos"):
|
|
self.click_position = event.pos()
|
|
return super().editorEvent(event, model, option, index)
|
|
|
|
def eventFilter(self, editor: Optional[QObject], event: Optional[QEvent]) -> bool:
|
|
"""By default, QPlainTextEdit doesn't handle enter or return"""
|
|
|
|
if editor is None or event is None:
|
|
return False
|
|
|
|
if event.type() == QEvent.Type.Show:
|
|
if self.click_position and isinstance(editor, QTextEdit):
|
|
# Map click position to editor's local space
|
|
local_click_position = editor.mapFromParent(self.click_position)
|
|
|
|
# Move cursor to the calculated position
|
|
cursor = editor.cursorForPosition(local_click_position)
|
|
editor.setTextCursor(cursor)
|
|
|
|
# Reset click position
|
|
self.click_position = None
|
|
|
|
return False
|
|
|
|
elif event.type() == QEvent.Type.KeyPress:
|
|
key_event = cast(QKeyEvent, event)
|
|
key = key_event.key()
|
|
if key == Qt.Key.Key_Return:
|
|
if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier):
|
|
self.commitData.emit(editor)
|
|
self.closeEditor.emit(editor)
|
|
return True
|
|
|
|
elif key == Qt.Key.Key_Escape:
|
|
# Close editor if no changes have been made
|
|
data_modified = False
|
|
if isinstance(editor, QTextEdit):
|
|
data_modified = (
|
|
self.original_model_data != editor.toPlainText()
|
|
)
|
|
elif isinstance(editor, QDoubleSpinBox):
|
|
data_modified = (
|
|
self.original_model_data != int(editor.value()) * 1000
|
|
)
|
|
if not data_modified:
|
|
self.closeEditor.emit(editor)
|
|
return True
|
|
|
|
discard_edits = QMessageBox.question(
|
|
cast(QWidget, self.parent()), "Abandon edit", "Discard changes?"
|
|
)
|
|
if discard_edits == QMessageBox.StandardButton.Yes:
|
|
self.closeEditor.emit(editor)
|
|
return True
|
|
|
|
return False
|
|
|
|
def sizeHint(self, option, index):
|
|
self.initStyleOption(option, index)
|
|
if self.current_editor:
|
|
doc = self.current_editor.document()
|
|
else:
|
|
doc = QTextDocument()
|
|
doc.setTextWidth(option.rect.width())
|
|
doc.setDefaultFont(option.font)
|
|
doc.setDocumentMargin(Config.ROW_PADDING)
|
|
if "\n" in option.text:
|
|
txt = option.text.replace("\n", "<br>")
|
|
elif "\u2028" in option.text:
|
|
txt = option.text.replace("\u2028", "<br>")
|
|
else:
|
|
txt = option.text
|
|
doc.setHtml(txt)
|
|
|
|
# For debugging +++
|
|
# Calculate sizes
|
|
# document_size = doc.documentLayout().documentSize()
|
|
# ideal_width = doc.idealWidth()
|
|
# height = document_size.height()
|
|
# rect_width = option.rect.width()
|
|
# text = option.text
|
|
|
|
# # Debug output
|
|
# print(f"Index: {index.row()}, {index.column()}")
|
|
# print(f"Text: {text}")
|
|
# print(f"Option.rect width: {rect_width}")
|
|
# print(f"Document idealWidth: {ideal_width}")
|
|
# print(f"Document height: {height}")
|
|
# print(f"---")
|
|
# --- For debugging
|
|
|
|
return QSize(int(doc.idealWidth()), int(doc.size().height()))
|
|
|
|
def setEditorData(self, editor, index):
|
|
proxy_model = index.model()
|
|
edit_index = proxy_model.mapToSource(index)
|
|
|
|
self.original_model_data = self.base_model.data(
|
|
edit_index, Qt.ItemDataRole.EditRole
|
|
)
|
|
if index.column() == Col.INTRO.value:
|
|
if self.original_model_data:
|
|
editor.setValue(self.original_model_data / 1000)
|
|
else:
|
|
editor.setPlainText(self.original_model_data)
|
|
|
|
def setModelData(self, editor, model, index):
|
|
proxy_model = index.model()
|
|
edit_index = proxy_model.mapToSource(index)
|
|
|
|
if isinstance(editor, QTextEdit):
|
|
value = editor.toPlainText().strip()
|
|
elif isinstance(editor, QDoubleSpinBox):
|
|
value = editor.value()
|
|
self.base_model.setData(edit_index, value, Qt.ItemDataRole.EditRole)
|
|
|
|
def updateEditorGeometry(self, editor, option, index):
|
|
editor.setGeometry(option.rect)
|
|
|
|
|
|
class PlaylistTab(QTableView):
|
|
"""
|
|
The playlist view
|
|
"""
|
|
|
|
def __init__(self, musicmuster: "Window", model: PlaylistProxyModel) -> None:
|
|
super().__init__()
|
|
|
|
# Save passed settings
|
|
self.musicmuster = musicmuster
|
|
|
|
self.playlist_id = model.sourceModel().playlist_id
|
|
self.track_sequence = TrackSequence()
|
|
|
|
# Set up widget
|
|
self.setItemDelegate(PlaylistDelegate(self, model.sourceModel()))
|
|
self.setAlternatingRowColors(True)
|
|
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
|
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
|
self.setDragDropOverwriteMode(False)
|
|
self.setAcceptDrops(True)
|
|
|
|
# Set our custom style - this draws the drop indicator across the whole row
|
|
self.setStyle(PlaylistStyle())
|
|
|
|
# We will enable dragging when rows are selected. Disabling it
|
|
# here means we can click and drag to select rows.
|
|
self.setDragEnabled(False)
|
|
|
|
# Prepare for context menu
|
|
self.menu = QMenu()
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.customContextMenuRequested.connect(self._context_menu)
|
|
|
|
# Connect signals
|
|
self.signals = MusicMusterSignals()
|
|
self.signals.resize_rows_signal.connect(self.resize_rows)
|
|
self.signals.span_cells_signal.connect(self._span_cells)
|
|
self.signals.signal_track_started.connect(self.track_started)
|
|
|
|
# Selection model
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
|
|
# Set up for Audacity
|
|
try:
|
|
self.ac: Optional[AudacityController] = AudacityController()
|
|
except ApplicationError as e:
|
|
self.ac = None
|
|
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
|
|
# Load model, set column widths
|
|
self.setModel(model)
|
|
self._set_column_widths()
|
|
|
|
# Stretch last column *after* setting column widths which is
|
|
# *much* faster
|
|
h_header = self.horizontalHeader()
|
|
if h_header:
|
|
h_header.sectionResized.connect(self._column_resize)
|
|
h_header.setStretchLastSection(True)
|
|
# Resize on vertical header click
|
|
v_header = self.verticalHeader()
|
|
if v_header:
|
|
v_header.setMinimumSectionSize(5)
|
|
v_header.sectionHandleDoubleClicked.disconnect()
|
|
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
|
|
|
|
# Setting ResizeToContents causes screen flash on load
|
|
self.resize_rows()
|
|
|
|
# ########## Overridden class functions ##########
|
|
|
|
def closeEditor(
|
|
self, editor: QWidget | None, hint: QAbstractItemDelegate.EndEditHint
|
|
) -> None:
|
|
"""
|
|
Override closeEditor to enable play controls and update display.
|
|
"""
|
|
|
|
self.musicmuster.enable_escape(True)
|
|
|
|
super(PlaylistTab, self).closeEditor(editor, hint)
|
|
|
|
# Optimise row heights after increasing row height for editing
|
|
self.resize_rows()
|
|
|
|
# Update start times in case a start time in a note has been
|
|
# edited
|
|
self.get_base_model().update_track_times()
|
|
|
|
# Deselect edited line
|
|
self.clear_selection()
|
|
|
|
# @log_call
|
|
def dropEvent(self, event: Optional[QDropEvent]) -> None:
|
|
"""
|
|
Move dropped rows
|
|
"""
|
|
|
|
if not event:
|
|
return
|
|
|
|
if event.source() is not self or (
|
|
event.dropAction() != Qt.DropAction.MoveAction
|
|
and self.dragDropMode() != QAbstractItemView.DragDropMode.InternalMove
|
|
):
|
|
return super().dropEvent(event)
|
|
|
|
from_rows = self.selected_model_row_numbers()
|
|
to_index = self.indexAt(event.position().toPoint())
|
|
|
|
# The drop indicator can either be immediately below a row or
|
|
# immediately above a row. There's about a 1 pixel difference,
|
|
# but we always want to drop between rows regardless of where
|
|
# drop indicator is.
|
|
if (
|
|
self.dropIndicatorPosition()
|
|
== QAbstractItemView.DropIndicatorPosition.BelowItem
|
|
):
|
|
# Drop on the row below
|
|
next_row = to_index.row() + 1
|
|
if next_row < self.model().rowCount(): # Ensure the row exists
|
|
destination_index = to_index.siblingAtRow(next_row)
|
|
else:
|
|
# Handle edge case where next_row is beyond the last row
|
|
destination_index = to_index
|
|
else:
|
|
destination_index = to_index
|
|
|
|
to_model_row = self.model().mapToSource(destination_index).row()
|
|
|
|
# Sanity check
|
|
base_model_row_count = self.get_base_model().rowCount()
|
|
if (
|
|
0 <= min(from_rows) <= base_model_row_count
|
|
and 0 <= to_model_row <= base_model_row_count
|
|
):
|
|
# If we move a row to immediately under the current track, make
|
|
# that moved row the next track
|
|
set_next_row: Optional[int] = None
|
|
if (
|
|
self.track_sequence.current
|
|
and to_model_row == self.track_sequence.current.row_number + 1
|
|
):
|
|
set_next_row = to_model_row
|
|
|
|
self.get_base_model().move_rows(from_rows, to_model_row)
|
|
|
|
# Reset drag mode to allow row selection by dragging
|
|
self.setDragEnabled(False)
|
|
|
|
# Deselect rows
|
|
self.clear_selection()
|
|
|
|
# Resize rows
|
|
self.resize_rows()
|
|
|
|
# Set next row if we are immediately under current row
|
|
if set_next_row:
|
|
self.get_base_model().set_next_row(set_next_row)
|
|
|
|
event.accept()
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
"""
|
|
Enable dragging if rows are selected
|
|
"""
|
|
|
|
if self.selectedIndexes():
|
|
self.setDragEnabled(True)
|
|
else:
|
|
self.setDragEnabled(False)
|
|
self.reset()
|
|
super().mouseReleaseEvent(event)
|
|
|
|
def resizeRowToContents(self, row):
|
|
super().resizeRowToContents(row)
|
|
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
|
|
|
|
def resizeRowsToContents(self):
|
|
header = self.verticalHeader()
|
|
for row in range(self.model().rowCount()):
|
|
hint = self.sizeHintForRow(row)
|
|
header.resizeSection(row, hint)
|
|
|
|
def selectionChanged(
|
|
self, selected: QItemSelection, deselected: QItemSelection
|
|
) -> None:
|
|
"""
|
|
Tell model which rows are selected.
|
|
|
|
Toggle drag behaviour according to whether rows are selected
|
|
"""
|
|
|
|
selected_row_numbers = self.get_selected_rows()
|
|
|
|
# Signal selected rows to model
|
|
self.signals.signal_playlist_selected_rows.emit(self.playlist_id, selected_row_numbers)
|
|
|
|
# Put sum of selected tracks' duration in status bar
|
|
# If no rows are selected, we have nothing to do
|
|
if len(selected_row_numbers) == 0:
|
|
self.musicmuster.lblSumPlaytime.setText("")
|
|
else:
|
|
if not self.musicmuster.disable_selection_timing:
|
|
selected_duration = self.get_base_model().get_rows_duration(
|
|
self.get_selected_rows()
|
|
)
|
|
if selected_duration > 0:
|
|
self.musicmuster.lblSumPlaytime.setText(
|
|
f"Selected duration: {ms_to_mmss(selected_duration)}"
|
|
)
|
|
else:
|
|
self.musicmuster.lblSumPlaytime.setText("")
|
|
else:
|
|
log.debug(
|
|
f"playlists.py.selectionChanged: {self.musicmuster.disable_selection_timing=}"
|
|
)
|
|
|
|
super().selectionChanged(selected, deselected)
|
|
|
|
# ########## Custom functions ##########
|
|
def _add_context_menu(
|
|
self,
|
|
text: str,
|
|
action: Callable,
|
|
disabled: bool = False,
|
|
parent_menu: Optional[QMenu] = None,
|
|
) -> Optional[QAction]:
|
|
"""
|
|
Add item to self.menu
|
|
"""
|
|
|
|
if parent_menu is None:
|
|
parent_menu = self.menu
|
|
|
|
menu_item = parent_menu.addAction(text)
|
|
if not menu_item:
|
|
return None
|
|
menu_item.setDisabled(disabled)
|
|
menu_item.triggered.connect(action)
|
|
|
|
return menu_item
|
|
|
|
def _add_track(self) -> None:
|
|
"""Add a track to a section header making it a normal track row"""
|
|
|
|
dlg = TrackInsertDialog(
|
|
parent=self.musicmuster,
|
|
playlist_id=self.playlist_id,
|
|
add_to_header=True,
|
|
)
|
|
dlg.exec()
|
|
|
|
def _build_context_menu(self, item: QTableWidgetItem) -> None:
|
|
"""Used to process context (right-click) menu, which is defined here"""
|
|
|
|
self.menu.clear()
|
|
|
|
index = self.model().index(item.row(), item.column())
|
|
model_row_number = self.model().mapToSource(index).row()
|
|
base_model = self.get_base_model()
|
|
|
|
header_row = self.get_base_model().is_header_row(model_row_number)
|
|
track_row = not header_row
|
|
if self.track_sequence.current:
|
|
this_is_current_row = model_row_number == self.track_sequence.current.row_number
|
|
else:
|
|
this_is_current_row = False
|
|
if self.track_sequence.next:
|
|
this_is_next_row = model_row_number == self.track_sequence.next.row_number
|
|
else:
|
|
this_is_next_row = False
|
|
track_path = base_model.get_row_info(model_row_number).path
|
|
|
|
# Open/import in/from Audacity
|
|
if track_row and not this_is_current_row:
|
|
if self.ac and track_path == self.ac.path:
|
|
# This track was opened in Audacity
|
|
self._add_context_menu(
|
|
"Update from Audacity",
|
|
lambda: self._import_from_audacity(model_row_number),
|
|
)
|
|
self._add_context_menu(
|
|
"Cancel Audacity",
|
|
lambda: self._cancel_audacity(),
|
|
)
|
|
else:
|
|
self._add_context_menu(
|
|
"Open in Audacity", lambda: self._open_in_audacity(model_row_number)
|
|
)
|
|
|
|
# Rescan
|
|
if track_row and not this_is_current_row:
|
|
self._add_context_menu(
|
|
"Rescan track", lambda: self._rescan(model_row_number)
|
|
)
|
|
self._add_context_menu("Mark for moving", lambda: self._mark_for_moving())
|
|
if self.musicmuster.move_source_rows:
|
|
self._add_context_menu(
|
|
"Move selected rows here", lambda: self._move_selected_rows()
|
|
)
|
|
|
|
# ----------------------
|
|
self.menu.addSeparator()
|
|
|
|
# Delete row
|
|
if not this_is_current_row and not this_is_next_row:
|
|
self._add_context_menu("Delete row", lambda: self._delete_rows())
|
|
|
|
# Remove track from row
|
|
if track_row and not this_is_current_row and not this_is_next_row:
|
|
self._add_context_menu(
|
|
"Remove track from row",
|
|
lambda: base_model.remove_track(model_row_number),
|
|
)
|
|
|
|
# Remove comments
|
|
self._add_context_menu("Remove comments", lambda: self._remove_comments())
|
|
|
|
# Add track to section header (ie, make this a track row)
|
|
if header_row:
|
|
self._add_context_menu("Add a track", lambda: self._add_track())
|
|
|
|
# # ----------------------
|
|
self.menu.addSeparator()
|
|
|
|
# Mark unplayed
|
|
if track_row and base_model.is_played_row(model_row_number):
|
|
self._add_context_menu(
|
|
"Mark unplayed",
|
|
lambda: self._mark_as_unplayed(self.get_selected_rows()),
|
|
)
|
|
|
|
# Unmark as next
|
|
if this_is_next_row:
|
|
self._add_context_menu(
|
|
"Unmark as next track", lambda: self._unmark_as_next()
|
|
)
|
|
|
|
# ----------------------
|
|
self.menu.addSeparator()
|
|
|
|
# Sort
|
|
sort_menu = self.menu.addMenu("Sort")
|
|
self._add_context_menu(
|
|
"by title",
|
|
lambda: base_model.sort_by_title(self.get_selected_rows()),
|
|
parent_menu=sort_menu,
|
|
)
|
|
self._add_context_menu(
|
|
"by artist",
|
|
lambda: base_model.sort_by_artist(self.get_selected_rows()),
|
|
parent_menu=sort_menu,
|
|
)
|
|
self._add_context_menu(
|
|
"by duration",
|
|
lambda: base_model.sort_by_duration(self.get_selected_rows()),
|
|
parent_menu=sort_menu,
|
|
)
|
|
self._add_context_menu(
|
|
"by last played",
|
|
lambda: base_model.sort_by_lastplayed(self.get_selected_rows()),
|
|
parent_menu=sort_menu,
|
|
)
|
|
self._add_context_menu(
|
|
"randomly",
|
|
lambda: base_model.sort_randomly(self.get_selected_rows()),
|
|
parent_menu=sort_menu,
|
|
)
|
|
|
|
# Info
|
|
if track_row:
|
|
self._add_context_menu("Info", lambda: self._info_row(model_row_number))
|
|
|
|
# Track path
|
|
if track_row:
|
|
self._add_context_menu(
|
|
"Copy track path", lambda: self._copy_path(model_row_number)
|
|
)
|
|
|
|
def _cancel_audacity(self) -> None:
|
|
"""
|
|
Cancel Audacity editing. We don't do anything with Audacity, just "forget"
|
|
that we have an edit open.
|
|
"""
|
|
|
|
if self.ac:
|
|
self.ac.path = None
|
|
|
|
def clear_selection(self) -> None:
|
|
"""Unselect all tracks and reset drag mode"""
|
|
|
|
self.clearSelection()
|
|
# We want to remove the focus from any widget otherwise keyboard
|
|
# activity may edit a cell.
|
|
fw = self.musicmuster.focusWidget()
|
|
if fw:
|
|
fw.clearFocus()
|
|
self.setDragEnabled(False)
|
|
|
|
def _column_resize(self, column_number: int, _old: int, _new: int) -> None:
|
|
"""
|
|
Called when column width changes. Save new width to database.
|
|
"""
|
|
|
|
header = self.horizontalHeader()
|
|
if not header:
|
|
return
|
|
|
|
# Resize rows if necessary
|
|
self.resizeRowsToContents()
|
|
|
|
# Save settings
|
|
ds.set_setting(
|
|
f"playlist_col_{column_number}_width", self.columnWidth(column_number)
|
|
)
|
|
|
|
def _context_menu(self, pos):
|
|
"""Display right-click menu"""
|
|
|
|
item = self.indexAt(pos)
|
|
self._build_context_menu(item)
|
|
self.menu.exec(self.mapToGlobal(pos))
|
|
|
|
def _copy_path(self, row_number: int) -> None:
|
|
"""
|
|
If passed row_number has a track, copy the track path, single-quoted,
|
|
to the clipboard. Otherwise, return None.
|
|
"""
|
|
|
|
track_path = self.get_base_model().get_row_info(row_number).path
|
|
if not track_path:
|
|
return
|
|
|
|
replacements = [
|
|
("'", "\\'"),
|
|
(" ", "\\ "),
|
|
("(", "\\("),
|
|
(")", "\\)"),
|
|
]
|
|
for old, new in replacements:
|
|
track_path = track_path.replace(old, new)
|
|
|
|
cb = QApplication.clipboard()
|
|
if cb:
|
|
cb.clear(mode=cb.Mode.Clipboard)
|
|
cb.setText(track_path, mode=cb.Mode.Clipboard)
|
|
|
|
# @log_call
|
|
def track_started(self, play_track: PlayTrack) -> None:
|
|
"""
|
|
Called when track starts playing
|
|
"""
|
|
|
|
if play_track.playlist_id != self.playlist_id:
|
|
# Not for us
|
|
return
|
|
|
|
# TODO - via signal
|
|
# self.get_base_model().current_track_started()
|
|
# Scroll to current section if hide mode is by section
|
|
if (
|
|
self.musicmuster.hide_played_tracks
|
|
and Config.HIDE_PLAYED_MODE == Config.HIDE_PLAYED_MODE_SECTIONS
|
|
):
|
|
# Hide section after delay
|
|
QTimer.singleShot(
|
|
Config.HIDE_AFTER_PLAYING_OFFSET + 100,
|
|
lambda: self.hide_played_sections(),
|
|
)
|
|
|
|
def _delete_rows(self) -> None:
|
|
"""
|
|
Delete mutliple rows
|
|
|
|
Actions required:
|
|
- Confirm deletion should go ahead
|
|
- Pass to model to do the deed
|
|
"""
|
|
|
|
rows_to_delete = self.get_selected_rows()
|
|
log.debug(f"_delete_rows({rows_to_delete=}")
|
|
row_count = len(rows_to_delete)
|
|
if row_count < 1:
|
|
return
|
|
|
|
# Don't delete current or next tracks
|
|
selected_row_numbers = self.selected_model_row_numbers()
|
|
for ts in [
|
|
self.track_sequence.next,
|
|
self.track_sequence.current,
|
|
]:
|
|
if ts:
|
|
if (
|
|
ts.playlist_id == self.playlist_id
|
|
and ts.row_number in selected_row_numbers
|
|
):
|
|
self.musicmuster.show_warning(
|
|
"Delete not allowed", "Can't delete current or next track"
|
|
)
|
|
return
|
|
|
|
# Get confirmation
|
|
plural = "s" if row_count > 1 else ""
|
|
if not ask_yes_no("Delete rows", f"Really delete {row_count} row{plural}?"):
|
|
return
|
|
|
|
base_model = self.get_base_model()
|
|
base_model.delete_rows(selected_row_numbers)
|
|
self.clear_selection()
|
|
|
|
def get_base_model(self) -> PlaylistModel:
|
|
"""
|
|
Return the base model for this proxy model
|
|
"""
|
|
|
|
return cast(PlaylistModel, self.model().sourceModel())
|
|
|
|
def get_selected_row_track_info(self) -> Optional[TrackInfo]:
|
|
"""
|
|
Return the track_id and row number of the selected
|
|
row. If no row selected or selected row does not have a track,
|
|
return None.
|
|
"""
|
|
|
|
selected_row = self.get_selected_row()
|
|
if selected_row is None:
|
|
return None
|
|
|
|
base_model = self.get_base_model()
|
|
model_row_number = self.source_model_selected_row_number()
|
|
|
|
if model_row_number is None:
|
|
return None
|
|
else:
|
|
track_id = base_model.get_row_track_id(model_row_number)
|
|
if not track_id:
|
|
return None
|
|
else:
|
|
return TrackInfo(track_id, selected_row)
|
|
|
|
# @log_call
|
|
def get_selected_row(self) -> Optional[int]:
|
|
"""
|
|
Return selected row number. If no rows or multiple rows selected, return None
|
|
"""
|
|
|
|
selected = self.get_selected_rows()
|
|
if len(selected) == 1:
|
|
return selected[0]
|
|
else:
|
|
return None
|
|
|
|
# @log_call
|
|
def get_selected_rows(self) -> list[int]:
|
|
"""Return a list of model-selected row numbers sorted by row"""
|
|
|
|
# Use a set to deduplicate result (a selected row will have all
|
|
# items in that row selected)
|
|
selected_indexes = self.selectedIndexes()
|
|
|
|
if not selected_indexes:
|
|
return []
|
|
|
|
return sorted(list(set([self.model().mapToSource(a).row() for a in selected_indexes])))
|
|
|
|
# @log_call
|
|
def get_top_visible_row(self) -> int:
|
|
"""
|
|
Get the viewport of the table view
|
|
"""
|
|
|
|
index = self.indexAt(QPoint(0, 0))
|
|
|
|
if index.isValid():
|
|
return index.row()
|
|
else:
|
|
# If no index is found, it means the table might be empty or scrolled beyond content
|
|
return -1
|
|
|
|
def hide_played_sections(self) -> None:
|
|
"""
|
|
Scroll played sections off screen, but only if current top row is above
|
|
the active header
|
|
"""
|
|
|
|
active_header_row = self.get_base_model().active_section_header()
|
|
if self.get_top_visible_row() < active_header_row:
|
|
self.scroll_to_top(active_header_row)
|
|
|
|
def _import_from_audacity(self, row_number: int) -> None:
|
|
"""
|
|
Import current Audacity track to passed row
|
|
"""
|
|
|
|
if not self.ac:
|
|
return
|
|
try:
|
|
self.ac.export()
|
|
self._rescan(row_number)
|
|
except ApplicationError as e:
|
|
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
self._cancel_audacity()
|
|
|
|
def _info_row(self, row_number: int) -> None:
|
|
"""Display popup with info re row"""
|
|
|
|
prd = self.get_base_model().get_row_info(row_number)
|
|
if prd:
|
|
txt = (
|
|
f"Title: {prd.title}\n"
|
|
f"Artist: {prd.artist}\n"
|
|
f"Track ID: {prd.track_id}\n"
|
|
f"Track duration: {ms_to_mmss(prd.duration)}\n"
|
|
f"Track bitrate: {prd.bitrate}\n"
|
|
"\n\n"
|
|
f"Path: {prd.path}\n"
|
|
)
|
|
else:
|
|
txt = f"Can't find info about row{row_number}"
|
|
|
|
show_OK("Track info", txt, self.musicmuster)
|
|
|
|
def _mark_as_unplayed(self, row_numbers: list[int]) -> None:
|
|
"""Mark row as unplayed"""
|
|
|
|
self.get_base_model().mark_unplayed(row_numbers)
|
|
self.clear_selection()
|
|
|
|
def _mark_for_moving(self) -> None:
|
|
"""
|
|
Mark selected rows for pasting
|
|
"""
|
|
|
|
self.musicmuster.mark_rows_for_moving()
|
|
|
|
def model(self) -> PlaylistProxyModel:
|
|
"""
|
|
Override return type to keep mypy happy in this module
|
|
"""
|
|
|
|
return cast(PlaylistProxyModel, super().model())
|
|
|
|
def _move_selected_rows(self) -> None:
|
|
"""
|
|
Move selected rows here
|
|
"""
|
|
|
|
self.musicmuster.paste_rows()
|
|
|
|
def _open_in_audacity(self, row_number: int) -> None:
|
|
"""
|
|
Open track in passed row in Audacity
|
|
"""
|
|
|
|
path = self.get_base_model().get_row_track_path(row_number)
|
|
if not path:
|
|
log.error(f"_open_in_audacity: can't get path for {row_number=}")
|
|
return
|
|
|
|
try:
|
|
if not self.ac:
|
|
self.ac = AudacityController()
|
|
self.ac.open(path)
|
|
except ApplicationError as e:
|
|
show_warning(self.musicmuster, "Audacity error", str(e))
|
|
|
|
def _remove_comments(self) -> None:
|
|
"""
|
|
Remove comments from selected rows
|
|
"""
|
|
|
|
row_numbers = self.selected_model_row_numbers()
|
|
if not row_numbers:
|
|
return
|
|
|
|
self.get_base_model().remove_comments(row_numbers)
|
|
|
|
def _rescan(self, row_number: int) -> None:
|
|
"""Rescan track"""
|
|
|
|
self.get_base_model().rescan_track(row_number)
|
|
self.clear_selection()
|
|
|
|
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
|
|
"""
|
|
If playlist_id is us, resize rows
|
|
"""
|
|
|
|
if playlist_id and playlist_id != self.playlist_id:
|
|
return
|
|
|
|
# Suggestion from phind.com
|
|
def resize_row(row, count=1):
|
|
row_count = self.model().rowCount()
|
|
for todo in range(count):
|
|
if row < row_count:
|
|
self.resizeRowToContents(row)
|
|
row += 1
|
|
if row < row_count:
|
|
QTimer.singleShot(0, lambda: resize_row(row, count))
|
|
|
|
# Start resizing from row 0, 10 rows at a time
|
|
QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
|
|
|
|
def scroll_to_top(self, row_number: int) -> None:
|
|
"""
|
|
Scroll to put passed row_number at the top of the displayed playlist.
|
|
"""
|
|
|
|
if row_number is None:
|
|
return
|
|
|
|
row_index = self.model().index(row_number, 0)
|
|
self.scrollTo(row_index, QAbstractItemView.ScrollHint.PositionAtTop)
|
|
|
|
def select_duplicate_rows(self) -> None:
|
|
"""
|
|
Select the last of any rows with duplicate tracks in current playlist.
|
|
This allows the selection to typically come towards the end of the playlist away
|
|
from any show specific sections.
|
|
"""
|
|
|
|
# Clear any selected rows to avoid confustion
|
|
self.clear_selection()
|
|
# We need to be in MultiSelection mode
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
|
|
# Get the duplicate rows
|
|
duplicate_rows = self.get_base_model().get_duplicate_rows()
|
|
# Select the rows
|
|
for duplicate_row in duplicate_rows:
|
|
self.selectRow(duplicate_row)
|
|
# Reset selection mode
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
|
|
# @log_call
|
|
def source_model_selected_row_number(self) -> Optional[int]:
|
|
"""
|
|
Return the model row number corresponding to the selected row or None
|
|
"""
|
|
|
|
selected_index = self._selected_row_index()
|
|
if selected_index is None:
|
|
return None
|
|
return self.model().mapToSource(selected_index).row()
|
|
|
|
# @log_call
|
|
def selected_model_row_numbers(self) -> list[int]:
|
|
"""
|
|
Return a list of model row numbers corresponding to the selected rows or
|
|
an empty list.
|
|
"""
|
|
|
|
selected_indexes = self._selected_row_indexes()
|
|
if selected_indexes is None:
|
|
return []
|
|
|
|
return [self.model().mapToSource(a).row() for a in selected_indexes]
|
|
|
|
def _selected_row_index(self) -> Optional[QModelIndex]:
|
|
"""
|
|
Return the selected row index or None if none selected.
|
|
"""
|
|
|
|
row_indexes = self._selected_row_indexes()
|
|
|
|
if len(row_indexes) > 1:
|
|
show_warning(
|
|
self.musicmuster, "Multiple rows selected", "Select only one row"
|
|
)
|
|
return None
|
|
elif not row_indexes:
|
|
return None
|
|
|
|
return row_indexes[0]
|
|
|
|
def _selected_row_indexes(self) -> list[QModelIndex]:
|
|
"""
|
|
Return a list of indexes of column 0 of selected rows
|
|
"""
|
|
|
|
sm = self.selectionModel()
|
|
if sm and sm.hasSelection():
|
|
return sm.selectedRows()
|
|
return []
|
|
|
|
def _set_column_widths(self) -> None:
|
|
"""Column widths from settings"""
|
|
|
|
header = self.horizontalHeader()
|
|
if not header:
|
|
return
|
|
|
|
# Last column is set to stretch so ignore it here
|
|
for column_number in range(header.count() - 1):
|
|
attr_name = f"playlist_col_{column_number}_width"
|
|
value = ds.get_setting(attr_name)
|
|
if value is not None:
|
|
self.setColumnWidth(column_number, value)
|
|
else:
|
|
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
|
|
|
def set_row_as_next_track(self) -> None:
|
|
"""
|
|
Set selected row as next track
|
|
"""
|
|
|
|
model_row_number = self.source_model_selected_row_number()
|
|
log.debug(f"set_row_as_next_track() {model_row_number=}")
|
|
if model_row_number is None:
|
|
return
|
|
self.get_base_model().set_next_row(model_row_number)
|
|
self.clearSelection()
|
|
|
|
def _span_cells(
|
|
self, playlist_id: int, row: int, column: int, rowSpan: int, columnSpan: int
|
|
) -> None:
|
|
"""
|
|
Implement spanning of cells, initiated by signal
|
|
|
|
row and column are from the base model so we need to translate
|
|
the row into this display row
|
|
"""
|
|
|
|
if playlist_id != self.playlist_id:
|
|
return
|
|
|
|
base_model = self.get_base_model()
|
|
|
|
cell_index = self.model().mapFromSource(base_model.createIndex(row, column))
|
|
row = cell_index.row()
|
|
column = cell_index.column()
|
|
|
|
# Don't set spanning if already in place because that is seen as
|
|
# a change to the view and thus it refreshes the data which
|
|
# again calls us here.
|
|
if (
|
|
self.rowSpan(row, column) == rowSpan
|
|
and self.columnSpan(row, column) == columnSpan
|
|
):
|
|
return
|
|
|
|
self.setSpan(row, column, rowSpan, columnSpan)
|
|
|
|
def tab_live(self) -> None:
|
|
"""
|
|
Called when tab gets focus
|
|
"""
|
|
|
|
# Update musicmuster
|
|
self.musicmuster.current.playlist_id = self.playlist_id
|
|
self.musicmuster.current.selected_row_numbers = self.get_selected_rows()
|
|
self.musicmuster.current.base_model = self.get_base_model()
|
|
self.musicmuster.current.proxy_model = self.model()
|
|
|
|
self.resize_rows()
|
|
|
|
def _unmark_as_next(self) -> None:
|
|
"""Rescan track"""
|
|
|
|
self.track_sequence.set_next(None)
|
|
self.clear_selection()
|
|
self.signals.next_track_changed_signal.emit()
|