# Standard library imports from typing import Callable, cast, List, Optional, TYPE_CHECKING import psutil import time # PyQt imports from PyQt6.QtCore import ( QEvent, QModelIndex, QObject, QItemSelection, Qt, QTimer, ) from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, QApplication, QHeaderView, QMenu, QMessageBox, QPlainTextEdit, QStyledItemDelegate, QStyleOptionViewItem, QTableView, QTableWidgetItem, QWidget, QProxyStyle, QStyle, QStyleOption, ) # Third party imports # App imports from classes import MusicMusterSignals, track_sequence from config import Config from dialogs import TrackSelectDialog from helpers import ( ask_yes_no, ms_to_mmss, show_OK, show_warning, ) from log import log from models import db, Settings from playlistmodel import PlaylistModel, PlaylistProxyModel if TYPE_CHECKING: from musicmuster import Window class EscapeDelegate(QStyledItemDelegate): """ - increases the height of a row when editing to make editing easier - closes the edit on control-return - checks with user before abandoning edit on Escape """ def __init__(self, parent, source_model: PlaylistModel) -> None: super().__init__(parent) self.source_model = source_model self.signals = MusicMusterSignals() def createEditor( self, parent: Optional[QWidget], option: QStyleOptionViewItem, index: QModelIndex, ): """ Intercept createEditor call and make row just a little bit taller """ self.signals = MusicMusterSignals() self.signals.enable_escape_signal.emit(False) if isinstance(self.parent(), PlaylistTab): p = cast(PlaylistTab, self.parent()) if isinstance(index.data(), str): row = index.row() row_height = p.rowHeight(row) p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) self.editor = QPlainTextEdit(parent) return self.editor return super().createEditor(parent, option, index) def destroyEditor(self, editor: Optional[QWidget], index: QModelIndex) -> None: """ Intercept editor destroyment """ self.signals.enable_escape_signal.emit(True) return super().destroyEditor(editor, 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.KeyPress: key_event = cast(QKeyEvent, event) if key_event.key() == Qt.Key.Key_Return: if key_event.modifiers() == (Qt.KeyboardModifier.ControlModifier): self.commitData.emit(editor) self.closeEditor.emit(editor) return True elif key_event.key() == Qt.Key.Key_Escape: if self.original_text == self.editor.toPlainText(): # No changes made 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 setEditorData(self, editor, index): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) self.original_text = self.source_model.data( edit_index, Qt.ItemDataRole.EditRole ) editor.setPlainText(self.original_text.value()) def setModelData(self, editor, model, index): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) value = editor.toPlainText().strip() self.source_model.setData(edit_index, value, Qt.ItemDataRole.EditRole) def updateEditorGeometry(self, editor, option, index): editor.setGeometry(option.rect) class PlaylistStyle(QProxyStyle): def drawPrimitive(self, element, option, painter, widget=None): """ Draw a line across the entire row rather than just the column we're hovering over. """ if ( element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop and not option.rect.isNull() ): option_new = QStyleOption(option) option_new.rect.setLeft(0) if widget: option_new.rect.setRight(widget.width()) option = option_new super().drawPrimitive(element, option, painter, widget) class PlaylistTab(QTableView): """ The playlist view """ def __init__( self, musicmuster: "Window", playlist_id: int, ) -> None: super().__init__() # Save passed settings self.musicmuster = musicmuster self.playlist_id = playlist_id log.debug(f"PlaylistTab.__init__({playlist_id=})") # Set up widget self.source_model = PlaylistModel(playlist_id) self.proxy_model = PlaylistProxyModel(self.source_model) self.setItemDelegate(EscapeDelegate(self, self.source_model)) 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) # Selection model self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) # Load playlist rows self.setModel(self.proxy_model) self._set_column_widths() # Stretch last column *after* setting column widths which is # *much* faster h_header = self.horizontalHeader() if isinstance(h_header, QHeaderView): h_header.sectionResized.connect(self._column_resize) h_header.setStretchLastSection(True) # 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.action_Clear_selection.setEnabled(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.source_model.update_track_times() # Deselect edited line self.clear_selection() def dropEvent(self, event): if event.source() is not self or ( event.dropAction() != Qt.DropAction.MoveAction and self.dragDropMode() != QAbstractItemView.InternalMove ): super().dropEvent(event) from_rows = self.selected_model_row_numbers() to_index = self.indexAt(event.position().toPoint()) to_model_row = self.proxy_model.mapToSource(to_index).row() log.info(f"PlaylistTab.dropEvent(): {from_rows=}, {to_index=}, {to_model_row=}") if ( 0 <= min(from_rows) <= self.source_model.rowCount() and 0 <= max(from_rows) <= self.source_model.rowCount() and 0 <= to_model_row <= self.source_model.rowCount() ): self.source_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() 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 selectionChanged( self, selected: QItemSelection, deselected: QItemSelection ) -> None: """ Toggle drag behaviour according to whether rows are selected """ selected_rows = self.get_selected_rows() # If no rows are selected, we have nothing to do if len(selected_rows) == 0: self.musicmuster.lblSumPlaytime.setText("") else: if not self.musicmuster.disable_selection_timing: selected_duration = self.source_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.info( 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""" model_row_number = self.source_model_selected_row_number() if model_row_number is None: return with db.Session() as session: dlg = TrackSelectDialog( session=session, new_row_number=model_row_number, source_model=self.source_model, add_to_header=True, ) dlg.exec() session.commit() def _audactity_command(self, cmd: str) -> bool: """ Send cmd to Audacity and monitor for response. Return True if successful else False. """ log.info(f"_audacity({cmd=})") # Notify user if audacity not running if "audacity" not in [i.name() for i in psutil.process_iter()]: log.warning("Audactity not running") show_warning(self.musicmuster, "Audacity", "Audacity is not running") return False if not self.musicmuster.audacity_client: self.musicmuster.initialise_audacity() if not self.musicmuster.audacity_client: log.error("Unable to access Audacity client") return False self.musicmuster.audacity_client.write(cmd, timer=True) reply = "" count = 0 while reply == "" and count < Config.AUDACITY_TIMEOUT_TENTHS: time.sleep(0.1) reply = self.musicmuster.audacity_client.read() count += 1 log.debug(f"_audactity_command: {count=}, {reply=}") status = False timing = "" msgs = reply.split("\n") for msg in msgs: if msg == "BatchCommand finished: OK": status = True elif msg.startswith("Execution time:"): timing = msg if not status: log.error(f"_audactity_command {msgs=}") return False if timing: log.info(f"_audactity_command {timing=}") return True def _build_context_menu(self, item: QTableWidgetItem) -> None: """Used to process context (right-click) menu, which is defined here""" self.menu.clear() proxy_model = self.proxy_model index = proxy_model.index(item.row(), item.column()) model_row_number = proxy_model.mapToSource(index).row() header_row = proxy_model.is_header_row(model_row_number) track_row = not header_row current_row = model_row_number == track_sequence.now.plr_rownum next_row = model_row_number == track_sequence.next.plr_rownum track_path = self.source_model.get_row_info(model_row_number).path # Open/import in/from Audacity if track_row and not current_row: if track_path == self.musicmuster.audacity_file_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 current_row: self._add_context_menu( "Rescan track", lambda: self._rescan(model_row_number) ) # ---------------------- self.menu.addSeparator() # Delete row if not current_row and not next_row: self._add_context_menu("Delete row", lambda: self._delete_rows()) # Remove track from row if track_row and not current_row and not next_row: self._add_context_menu( "Remove track from row", lambda: proxy_model.remove_track(model_row_number), ) # 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 proxy_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 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: proxy_model.sort_by_title(self.get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by artist", lambda: proxy_model.sort_by_artist(self.get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by duration", lambda: proxy_model.sort_by_duration(self.get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "by last played", lambda: proxy_model.sort_by_lastplayed(self.get_selected_rows()), parent_menu=sort_menu, ) self._add_context_menu( "randomly", lambda: proxy_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): """ Cancel Audacity editing. We don't do anything with Audacity, just "forget" that we have an edit open. """ self.musicmuster.audacity_file_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. """ log.debug(f"_column_resize({column_number=}, {_old=}, {_new=}") header = self.horizontalHeader() if not header: return # Resize rows if necessary self.resizeRowsToContents() with db.Session() as session: attr_name = f"playlist_col_{column_number}_width" record = Settings.get_int_settings(session, attr_name) record.f_int = self.columnWidth(column_number) session.commit() 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.source_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) 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.info(f"_delete_rows({rows_to_delete=}") row_count = len(rows_to_delete) if row_count < 1: 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 self.source_model.delete_rows(self.selected_model_row_numbers()) self.clear_selection() def get_selected_row_track_path(self) -> str: """ Return the path of the selected row. If no row selected or selected row does not have a track, return empty string. """ log.debug("get_selected_row_track_path() called") model_row_number = self.source_model_selected_row_number() if model_row_number is None: result = "" else: result = self.source_model.get_row_track_path(model_row_number) log.info(f"get_selected_row_track_path() returned: {result=}") return result def get_selected_rows(self) -> List[int]: """Return a list of model-selected row numbers sorted by row""" log.debug("get_selected_rows() called") # Use a set to deduplicate result (a selected row will have all # items in that row selected) result = sorted( list( set( [ self.proxy_model.mapToSource(a).row() for a in self.selectedIndexes() ] ) ) ) log.debug(f"get_selected_rows() returned: {result=}") return result def _import_from_audacity(self, row_number: int) -> None: """ Import current Audacity track to passed row """ path = self.source_model.get_row_track_path(row_number) if not path: log.error(f"_import_from_audacity: can't get path for {row_number=}") return select_cmd = "SelectAll:" status = self._audactity_command(select_cmd) if not status: log.error(f"_import_from_audacity select {status=}") show_warning( self.musicmuster, "Audacity", "Error selecting track in Audacity" ) return export_cmd = f'Export2: Filename="{path}" NumChannels=2' status = self._audactity_command(export_cmd) if not status: log.error(f"_import_from_audacity export {status=}") show_warning( self.musicmuster, "Audacity", "Error exporting track from Audacity" ) return self.musicmuster.audacity_file_path = None self._rescan(row_number) def _info_row(self, row_number: int) -> None: """Display popup with info re row""" prd = self.source_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(self.musicmuster, "Track info", txt) def _mark_as_unplayed(self, row_numbers: List[int]) -> None: """Mark row as unplayed""" self.source_model.mark_unplayed(row_numbers) self.clear_selection() def _open_in_audacity(self, row_number: int) -> None: """ Open track in passed row in Audacity """ if not self.musicmuster.audacity_client: self.musicmuster.initialise_audacity() path = self.source_model.get_row_track_path(row_number) if not path: log.error(f"_open_in_audacity: can't get path for {row_number=}") return escaped_path = path.replace('"', '\\"') cmd = f'Import2: Filename="{escaped_path}"' status = self._audactity_command(cmd) if status: self.musicmuster.audacity_file_path = path log.info(f"_open_in_audacity {path=}, {status=}") def _rescan(self, row_number: int) -> None: """Rescan track""" self.source_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 """ log.debug(f"resize_rows({playlist_id=}) {self.playlist_id=}") if playlist_id and playlist_id != self.playlist_id: return # Suggestion from phind.com def resize_row(row, count=1): row_count = self.source_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, 10)) def scroll_to_top(self, row_number: int) -> None: """ Scroll to put passed row_number Config.SCROLL_TOP_MARGIN from the top. """ if row_number is None: return row_index = self.proxy_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.source_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) 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.proxy_model.mapToSource(selected_index).row() 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 [] if hasattr(self.proxy_model, "mapToSource"): return [self.proxy_model.mapToSource(a).row() for a in selected_indexes] return [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 1 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""" log.debug("_set_column_widths()") header = self.horizontalHeader() if not header: return # Last column is set to stretch so ignore it here with db.Session() as session: for column_number in range(header.count() - 1): attr_name = f"playlist_col_{column_number}_width" record = Settings.get_int_settings(session, attr_name) if record.f_int is not None: self.setColumnWidth(column_number, record.f_int) 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.info(f"set_row_as_next_track() {model_row_number=}") if model_row_number is None: return self.source_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 """ log.debug( f"_span_cells({playlist_id=}, {row=}, " f"{column=}, {rowSpan=}, {columnSpan=}) {self.playlist_id=}" ) if playlist_id != self.playlist_id: return proxy_model = self.proxy_model edit_index = proxy_model.mapFromSource( self.source_model.createIndex(row, column) ) row = edit_index.row() column = edit_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 _unmark_as_next(self) -> None: """Rescan track""" self.source_model.set_next_row(None) self.clear_selection()