895 lines
29 KiB
Python
895 lines
29 KiB
Python
# 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.info(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()
|
|
|
|
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.info(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)
|
|
|
|
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.info(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.info(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.info("_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()
|