WIP: query tabs
This commit is contained in:
parent
71fad71ed0
commit
15bb83fc50
@ -31,6 +31,7 @@ class Config(object):
|
|||||||
COLOUR_NORMAL_TAB = "#000000"
|
COLOUR_NORMAL_TAB = "#000000"
|
||||||
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
COLOUR_NOTES_PLAYLIST = "#b8daff"
|
||||||
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
COLOUR_ODD_PLAYLIST = "#f2f2f2"
|
||||||
|
COLOUR_QUERYLIST_CHECKED = "#d3ffd3"
|
||||||
COLOUR_UNREADABLE = "#dc3545"
|
COLOUR_UNREADABLE = "#dc3545"
|
||||||
COLOUR_WARNING_TIMER = "#ffc107"
|
COLOUR_WARNING_TIMER = "#ffc107"
|
||||||
DBFS_SILENCE = -50
|
DBFS_SILENCE = -50
|
||||||
|
|||||||
@ -62,7 +62,7 @@ from log import log
|
|||||||
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
|
||||||
from music_manager import RowAndTrack, track_sequence
|
from music_manager import RowAndTrack, track_sequence
|
||||||
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
from playlistmodel import PlaylistModel, PlaylistProxyModel
|
||||||
from querylistmodel import QuerylistModel, QuerylistProxyModel
|
from querylistmodel import QuerylistModel
|
||||||
from playlists import PlaylistTab
|
from playlists import PlaylistTab
|
||||||
from querylists import QuerylistTab
|
from querylists import QuerylistTab
|
||||||
from ui import icons_rc # noqa F401
|
from ui import icons_rc # noqa F401
|
||||||
@ -680,11 +680,9 @@ class Window(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
# Create model and proxy model
|
# Create model and proxy model
|
||||||
base_model = QuerylistModel(querylist.id)
|
base_model = QuerylistModel(querylist.id)
|
||||||
proxy_model = QuerylistProxyModel()
|
|
||||||
proxy_model.setSourceModel(base_model)
|
|
||||||
|
|
||||||
# Create tab
|
# Create tab
|
||||||
querylist_tab = QuerylistTab(musicmuster=self, model=proxy_model)
|
querylist_tab = QuerylistTab(musicmuster=self, model=base_model)
|
||||||
idx = self.tabPlaylist.addTab(querylist_tab, querylist.name)
|
idx = self.tabPlaylist.addTab(querylist_tab, querylist.name)
|
||||||
|
|
||||||
log.debug(f"create_querylist_tab() returned: {idx=}")
|
log.debug(f"create_querylist_tab() returned: {idx=}")
|
||||||
|
|||||||
@ -497,7 +497,7 @@ class PlaylistModel(QAbstractTableModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return Qt.ItemFlag.ItemIsDropEnabled
|
return Qt.ItemFlag.NoItemFlags
|
||||||
|
|
||||||
default = (
|
default = (
|
||||||
Qt.ItemFlag.ItemIsEnabled
|
Qt.ItemFlag.ItemIsEnabled
|
||||||
|
|||||||
@ -22,10 +22,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QFrame,
|
QFrame,
|
||||||
QMenu,
|
QMenu,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QProxyStyle,
|
|
||||||
QStyle,
|
|
||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
QStyleOption,
|
|
||||||
QStyleOptionViewItem,
|
QStyleOptionViewItem,
|
||||||
QTableView,
|
QTableView,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
@ -37,7 +34,7 @@ from PyQt6.QtWidgets import (
|
|||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from audacity_controller import AudacityController
|
from audacity_controller import AudacityController
|
||||||
from classes import ApplicationError, QueryCol, MusicMusterSignals, PlaylistStyle, TrackInfo
|
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
|
||||||
from config import Config
|
from config import Config
|
||||||
from dialogs import TrackSelectDialog
|
from dialogs import TrackSelectDialog
|
||||||
from helpers import (
|
from helpers import (
|
||||||
@ -112,7 +109,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
if self.current_editor:
|
if self.current_editor:
|
||||||
editor = self.current_editor
|
editor = self.current_editor
|
||||||
else:
|
else:
|
||||||
if index.column() == QueryCol.INTRO.value:
|
if index.column() == Col.INTRO.value:
|
||||||
editor = QDoubleSpinBox(parent)
|
editor = QDoubleSpinBox(parent)
|
||||||
editor.setDecimals(1)
|
editor.setDecimals(1)
|
||||||
editor.setSingleStep(0.1)
|
editor.setSingleStep(0.1)
|
||||||
@ -248,7 +245,7 @@ class PlaylistDelegate(QStyledItemDelegate):
|
|||||||
self.original_model_data = self.base_model.data(
|
self.original_model_data = self.base_model.data(
|
||||||
edit_index, Qt.ItemDataRole.EditRole
|
edit_index, Qt.ItemDataRole.EditRole
|
||||||
)
|
)
|
||||||
if index.column() == QueryCol.INTRO.value:
|
if index.column() == Col.INTRO.value:
|
||||||
if self.original_model_data.value():
|
if self.original_model_data.value():
|
||||||
editor.setValue(self.original_model_data.value() / 1000)
|
editor.setValue(self.original_model_data.value() / 1000)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -2,21 +2,18 @@
|
|||||||
# Allow forward reference to PlaylistModel
|
# Allow forward reference to PlaylistModel
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from operator import attrgetter
|
from typing import cast
|
||||||
from random import shuffle
|
|
||||||
from typing import cast, Optional
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
QAbstractTableModel,
|
QAbstractTableModel,
|
||||||
QModelIndex,
|
QModelIndex,
|
||||||
QObject,
|
|
||||||
QRegularExpression,
|
QRegularExpression,
|
||||||
QSortFilterProxyModel,
|
QSortFilterProxyModel,
|
||||||
Qt,
|
Qt,
|
||||||
QTimer,
|
|
||||||
QVariant,
|
QVariant,
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
@ -26,29 +23,22 @@ from PyQt6.QtGui import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
|
||||||
import obswebsocket # type: ignore
|
|
||||||
|
|
||||||
# import snoop # type: ignore
|
# import snoop # type: ignore
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from classes import (
|
from classes import (
|
||||||
QueryCol,
|
QueryCol,
|
||||||
MusicMusterSignals,
|
|
||||||
)
|
)
|
||||||
from config import Config
|
from config import Config
|
||||||
from helpers import (
|
from helpers import (
|
||||||
ask_yes_no,
|
|
||||||
file_is_unreadable,
|
file_is_unreadable,
|
||||||
get_embedded_time,
|
|
||||||
get_relative_date,
|
get_relative_date,
|
||||||
ms_to_mmss,
|
ms_to_mmss,
|
||||||
remove_substring_case_insensitive,
|
|
||||||
set_track_metadata,
|
|
||||||
)
|
)
|
||||||
from log import log
|
from log import log
|
||||||
from models import db, NoteColours, Playdates, PlaylistRows, Tracks
|
from models import db, Playdates
|
||||||
from music_manager import RowAndTrack, track_sequence
|
from music_manager import RowAndTrack
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -82,10 +72,7 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.querylist_rows: dict[int, QueryRow] = {}
|
self.querylist_rows: dict[int, QueryRow] = {}
|
||||||
self.signals = MusicMusterSignals()
|
self._selected_rows: set[int] = set()
|
||||||
|
|
||||||
self.signals.begin_reset_model_signal.connect(self.begin_reset_model)
|
|
||||||
self.signals.end_reset_model_signal.connect(self.end_reset_model)
|
|
||||||
|
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
# Populate self.playlist_rows
|
# Populate self.playlist_rows
|
||||||
@ -96,32 +83,27 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
|
f"<QuerylistModel: playlist_id={self.playlist_id}, {self.rowCount()} rows>"
|
||||||
)
|
)
|
||||||
|
|
||||||
def background_role(self, row: int, column: int, qrow: QueryRow) -> QBrush:
|
def background_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
|
||||||
"""Return background setting"""
|
"""Return background setting"""
|
||||||
|
|
||||||
# Unreadable track file
|
# Unreadable track file
|
||||||
if file_is_unreadable(qrow.path):
|
if file_is_unreadable(qrow.path):
|
||||||
return QBrush(QColor(Config.COLOUR_UNREADABLE))
|
return QVariant(QColor(Config.COLOUR_UNREADABLE))
|
||||||
|
|
||||||
|
# Selected row
|
||||||
|
if row in self._selected_rows:
|
||||||
|
return QVariant(QColor(Config.COLOUR_QUERYLIST_CHECKED))
|
||||||
|
|
||||||
# Individual cell colouring
|
# Individual cell colouring
|
||||||
if column == QueryCol.BITRATE.value:
|
if column == QueryCol.BITRATE.value:
|
||||||
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
|
if not qrow.bitrate or qrow.bitrate < Config.BITRATE_LOW_THRESHOLD:
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_LOW))
|
return QVariant(QColor(Config.COLOUR_BITRATE_LOW))
|
||||||
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
|
elif qrow.bitrate < Config.BITRATE_OK_THRESHOLD:
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_MEDIUM))
|
return QVariant(QColor(Config.COLOUR_BITRATE_MEDIUM))
|
||||||
else:
|
else:
|
||||||
return QBrush(QColor(Config.COLOUR_BITRATE_OK))
|
return QVariant(QColor(Config.COLOUR_BITRATE_OK))
|
||||||
|
|
||||||
return QBrush()
|
return QVariant()
|
||||||
|
|
||||||
def begin_reset_model(self, playlist_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Reset model if playlist_id is ours
|
|
||||||
"""
|
|
||||||
|
|
||||||
if playlist_id != self.playlist_id:
|
|
||||||
return
|
|
||||||
super().beginResetModel()
|
|
||||||
|
|
||||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||||
"""Standard function for view"""
|
"""Standard function for view"""
|
||||||
@ -142,10 +124,10 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
qrow = self.querylist_rows[row]
|
qrow = self.querylist_rows[row]
|
||||||
|
|
||||||
# Dispatch to role-specific functions
|
# Dispatch to role-specific functions
|
||||||
dispatch_table = {
|
dispatch_table: dict[int, Callable] = {
|
||||||
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
|
int(Qt.ItemDataRole.BackgroundRole): self.background_role,
|
||||||
int(Qt.ItemDataRole.DisplayRole): self.display_role,
|
int(Qt.ItemDataRole.DisplayRole): self.display_role,
|
||||||
int(Qt.ItemDataRole.EditRole): self.edit_role,
|
int(Qt.ItemDataRole.ToolTipRole): self.tooltip_role,
|
||||||
}
|
}
|
||||||
|
|
||||||
if role in dispatch_table:
|
if role in dispatch_table:
|
||||||
@ -153,8 +135,8 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
# Document other roles but don't use them
|
# Document other roles but don't use them
|
||||||
if role in [
|
if role in [
|
||||||
Qt.ItemDataRole.CheckStateRole,
|
|
||||||
Qt.ItemDataRole.DecorationRole,
|
Qt.ItemDataRole.DecorationRole,
|
||||||
|
Qt.ItemDataRole.EditRole,
|
||||||
Qt.ItemDataRole.FontRole,
|
Qt.ItemDataRole.FontRole,
|
||||||
Qt.ItemDataRole.ForegroundRole,
|
Qt.ItemDataRole.ForegroundRole,
|
||||||
Qt.ItemDataRole.InitialSortOrderRole,
|
Qt.ItemDataRole.InitialSortOrderRole,
|
||||||
@ -186,102 +168,60 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
|
|
||||||
return QVariant()
|
return QVariant()
|
||||||
|
|
||||||
def end_reset_model(self, playlist_id: int) -> None:
|
|
||||||
"""
|
|
||||||
End model reset if this is our playlist
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{self}: end_reset_model({playlist_id=})")
|
|
||||||
|
|
||||||
if playlist_id != self.playlist_id:
|
|
||||||
log.debug(f"{self}: end_reset_model: not us ({self.playlist_id=})")
|
|
||||||
return
|
|
||||||
with db.Session() as session:
|
|
||||||
self.refresh_data(session)
|
|
||||||
super().endResetModel()
|
|
||||||
self.reset_track_sequence_row_numbers()
|
|
||||||
|
|
||||||
def edit_role(self, row: int, column: int, qrow: QueryRow) -> QVariant:
|
|
||||||
"""
|
|
||||||
Return text for editing
|
|
||||||
"""
|
|
||||||
|
|
||||||
if column == QueryCol.TITLE.value:
|
|
||||||
return QVariant(qrow.title)
|
|
||||||
if column == QueryCol.ARTIST.value:
|
|
||||||
return QVariant(qrow.artist)
|
|
||||||
|
|
||||||
return QVariant()
|
|
||||||
|
|
||||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||||
"""
|
"""
|
||||||
Standard model flags
|
Standard model flags
|
||||||
"""
|
"""
|
||||||
|
|
||||||
default = (
|
if not index.isValid():
|
||||||
Qt.ItemFlag.ItemIsEnabled
|
return Qt.ItemFlag.NoItemFlags
|
||||||
| Qt.ItemFlag.ItemIsSelectable
|
|
||||||
)
|
|
||||||
if index.column() in [
|
|
||||||
QueryCol.TITLE.value,
|
|
||||||
QueryCol.ARTIST.value,
|
|
||||||
]:
|
|
||||||
return default | Qt.ItemFlag.ItemIsEditable
|
|
||||||
|
|
||||||
return default
|
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
||||||
|
|
||||||
def get_row_info(self, row_number: int) -> RowAndTrack:
|
def get_selected_track_ids(self) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Return info about passed row
|
Return a list of track_ids from selected tracks
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.querylist_rows[row_number]
|
return [self.querylist_rows[row].track_id for row in self._selected_rows]
|
||||||
|
|
||||||
def get_row_track_id(self, row_number: int) -> Optional[int]:
|
def headerData(
|
||||||
|
self,
|
||||||
|
section: int,
|
||||||
|
orientation: Qt.Orientation,
|
||||||
|
role: int = Qt.ItemDataRole.DisplayRole,
|
||||||
|
) -> QVariant:
|
||||||
"""
|
"""
|
||||||
Return id of track associated with row or None if no track associated
|
Return text for headers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.querylist_rows[row_number].track_id
|
display_dispatch_table = {
|
||||||
|
QueryCol.TITLE.value: QVariant(Config.HEADER_TITLE),
|
||||||
|
QueryCol.ARTIST.value: QVariant(Config.HEADER_ARTIST),
|
||||||
|
QueryCol.DURATION.value: QVariant(Config.HEADER_DURATION),
|
||||||
|
QueryCol.LAST_PLAYED.value: QVariant(Config.HEADER_LAST_PLAYED),
|
||||||
|
QueryCol.BITRATE.value: QVariant(Config.HEADER_BITRATE),
|
||||||
|
}
|
||||||
|
|
||||||
def get_row_track_path(self, row_number: int) -> str:
|
if role == Qt.ItemDataRole.DisplayRole:
|
||||||
"""
|
if orientation == Qt.Orientation.Horizontal:
|
||||||
Return path of track associated with row or empty string if no track associated
|
return display_dispatch_table[section]
|
||||||
"""
|
else:
|
||||||
|
if Config.ROWS_FROM_ZERO:
|
||||||
|
return QVariant(str(section))
|
||||||
|
else:
|
||||||
|
return QVariant(str(section + 1))
|
||||||
|
|
||||||
return self.querylist_rows[row_number].path
|
elif role == Qt.ItemDataRole.FontRole:
|
||||||
|
boldfont = QFont()
|
||||||
|
boldfont.setBold(True)
|
||||||
|
return QVariant(boldfont)
|
||||||
|
|
||||||
def get_rows_duration(self, row_numbers: list[int]) -> int:
|
return QVariant()
|
||||||
"""
|
|
||||||
Return the total duration of the passed rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
duration = 0
|
|
||||||
for row_number in row_numbers:
|
|
||||||
duration += self.querylist_rows[row_number].duration
|
|
||||||
|
|
||||||
return duration
|
|
||||||
|
|
||||||
def invalidate_row(self, modified_row: int) -> None:
|
|
||||||
"""
|
|
||||||
Signal to view to refresh invalidated row
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.dataChanged.emit(
|
|
||||||
self.index(modified_row, 0),
|
|
||||||
self.index(modified_row, self.columnCount() - 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
def invalidate_rows(self, modified_rows: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Signal to view to refresh invlidated rows
|
|
||||||
"""
|
|
||||||
|
|
||||||
for modified_row in modified_rows:
|
|
||||||
self.invalidate_row(modified_row)
|
|
||||||
|
|
||||||
def load_data(
|
def load_data(
|
||||||
self, session: db.session,
|
self,
|
||||||
|
session: db.session,
|
||||||
sql: str = """
|
sql: str = """
|
||||||
SELECT
|
SELECT
|
||||||
tracks.*,playdates.lastplayed
|
tracks.*,playdates.lastplayed
|
||||||
@ -296,7 +236,7 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR)
|
MAX(playdates.lastplayed) < DATE_SUB(NOW(), INTERVAL 1 YEAR)
|
||||||
ORDER BY tracks.title
|
ORDER BY tracks.title
|
||||||
;
|
;
|
||||||
"""
|
""",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Load data from user-defined query. Can probably hard-code the SELECT part
|
Load data from user-defined query. Can probably hard-code the SELECT part
|
||||||
@ -315,380 +255,50 @@ class QuerylistModel(QAbstractTableModel):
|
|||||||
results = session.execute(text(sql)).mappings().all()
|
results = session.execute(text(sql)).mappings().all()
|
||||||
for result in results:
|
for result in results:
|
||||||
queryrow = QueryRow(
|
queryrow = QueryRow(
|
||||||
artist=result['artist'],
|
artist=result["artist"],
|
||||||
bitrate=result['bitrate'],
|
bitrate=result["bitrate"],
|
||||||
duration=result['duration'],
|
duration=result["duration"],
|
||||||
lastplayed=result['lastplayed'],
|
lastplayed=result["lastplayed"],
|
||||||
path=result['path'],
|
path=result["path"],
|
||||||
title=result['title'],
|
title=result["title"],
|
||||||
track_id=result['id'],
|
track_id=result["id"],
|
||||||
)
|
)
|
||||||
self.querylist_rows[row] = queryrow
|
self.querylist_rows[row] = queryrow
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# # Note where each playlist_id is
|
|
||||||
# plid_to_row: dict[int, int] = {}
|
|
||||||
# for oldrow in self.playlist_rows:
|
|
||||||
# plrdata = self.playlist_rows[oldrow]
|
|
||||||
# plid_to_row[plrdata.playlistrow_id] = plrdata.row_number
|
|
||||||
|
|
||||||
# # build a new playlist_rows
|
|
||||||
# new_playlist_rows: dict[int, RowAndTrack] = {}
|
|
||||||
# for p in PlaylistRows.get_playlist_rows(session, self.playlist_id):
|
|
||||||
# if p.id not in plid_to_row:
|
|
||||||
# new_playlist_rows[p.row_number] = RowAndTrack(p)
|
|
||||||
# else:
|
|
||||||
# new_playlist_rows[p.row_number] = self.playlist_rows[plid_to_row[p.id]]
|
|
||||||
# new_playlist_rows[p.row_number].row_number = p.row_number
|
|
||||||
|
|
||||||
# # Copy to self.playlist_rows
|
|
||||||
# self.playlist_rows = new_playlist_rows
|
|
||||||
|
|
||||||
def move_rows(
|
|
||||||
self,
|
|
||||||
from_rows: list[int],
|
|
||||||
to_row_number: int,
|
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Move the playlist rows given to to_row and below.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{self}: move_rows({from_rows=}, {to_row_number=}")
|
|
||||||
|
|
||||||
# Build a {current_row_number: new_row_number} dictionary
|
|
||||||
row_map: dict[int, int] = {}
|
|
||||||
|
|
||||||
# The destination row number will need to be reduced by the
|
|
||||||
# number of rows being move from above the destination row
|
|
||||||
# otherwise rows below the destination row will end up above the
|
|
||||||
# moved rows.
|
|
||||||
adjusted_to_row = to_row_number - len(
|
|
||||||
[a for a in from_rows if a < to_row_number]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Put the from_row row numbers into the row_map. Ultimately the
|
|
||||||
# total number of elements in the playlist doesn't change, so
|
|
||||||
# check that adding the moved rows starting at to_row won't
|
|
||||||
# overshoot the end of the playlist.
|
|
||||||
if adjusted_to_row + len(from_rows) > len(self.querylist_rows):
|
|
||||||
next_to_row = len(self.querylist_rows) - len(from_rows)
|
|
||||||
else:
|
|
||||||
next_to_row = adjusted_to_row
|
|
||||||
|
|
||||||
# zip iterates from_row and to_row simultaneously from the
|
|
||||||
# respective sequences inside zip()
|
|
||||||
for from_row, to_row in zip(
|
|
||||||
from_rows, range(next_to_row, next_to_row + len(from_rows))
|
|
||||||
):
|
|
||||||
row_map[from_row] = to_row
|
|
||||||
|
|
||||||
# Move the remaining rows to the row_map. We want to fill it
|
|
||||||
# before (if there are gaps) and after (likewise) the rows that
|
|
||||||
# are moving.
|
|
||||||
# zip iterates old_row and new_row simultaneously from the
|
|
||||||
# respective sequences inside zip()
|
|
||||||
for old_row, new_row in zip(
|
|
||||||
[x for x in self.querylist_rows.keys() if x not in from_rows],
|
|
||||||
[y for y in range(len(self.querylist_rows)) if y not in row_map.values()],
|
|
||||||
):
|
|
||||||
# Optimise: only add to map if there is a change
|
|
||||||
if old_row != new_row:
|
|
||||||
row_map[old_row] = new_row
|
|
||||||
|
|
||||||
# For SQLAlchemy, build a list of dictionaries that map playlistrow_id to
|
|
||||||
# new row number:
|
|
||||||
sqla_map: list[dict[str, int]] = []
|
|
||||||
for oldrow, newrow in row_map.items():
|
|
||||||
playlistrow_id = self.querylist_rows[oldrow].playlistrow_id
|
|
||||||
sqla_map.append({"playlistrow_id": playlistrow_id, "row_number": newrow})
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
PlaylistRows.update_plr_row_numbers(session, self.playlist_id, sqla_map)
|
|
||||||
session.commit()
|
|
||||||
# Update playlist_rows
|
|
||||||
self.refresh_data(session)
|
|
||||||
|
|
||||||
# Update display
|
|
||||||
self.reset_track_sequence_row_numbers()
|
|
||||||
self.invalidate_rows(list(row_map.keys()))
|
|
||||||
|
|
||||||
@line_profiler.profile
|
|
||||||
def move_rows_between_playlists(
|
|
||||||
self,
|
|
||||||
from_rows: list[int],
|
|
||||||
to_row_number: int,
|
|
||||||
to_playlist_id: int,
|
|
||||||
dummy_for_profiling: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Move the playlist rows given to to_row and below of to_playlist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
f"{self}: move_rows_between_playlists({from_rows=}, "
|
|
||||||
f"{to_row_number=}, {to_playlist_id=}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Row removal must be wrapped in beginRemoveRows ..
|
|
||||||
# endRemoveRows and the row range must be contiguous. Process
|
|
||||||
# the highest rows first so the lower row numbers are unchanged
|
|
||||||
row_groups = self._reversed_contiguous_row_groups(from_rows)
|
|
||||||
|
|
||||||
# Prepare destination playlist for a reset
|
|
||||||
self.signals.begin_reset_model_signal.emit(to_playlist_id)
|
|
||||||
|
|
||||||
with db.Session() as session:
|
|
||||||
for row_group in row_groups:
|
|
||||||
# Make room in destination playlist
|
|
||||||
max_destination_row_number = PlaylistRows.get_last_used_row(
|
|
||||||
session, to_playlist_id
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
max_destination_row_number
|
|
||||||
and to_row_number <= max_destination_row_number
|
|
||||||
):
|
|
||||||
# Move the destination playlist rows down to make room.
|
|
||||||
PlaylistRows.move_rows_down(
|
|
||||||
session, to_playlist_id, to_row_number, len(row_group)
|
|
||||||
)
|
|
||||||
next_to_row = to_row_number
|
|
||||||
|
|
||||||
super().beginRemoveRows(QModelIndex(), min(row_group), max(row_group))
|
|
||||||
for playlist_row in PlaylistRows.plrids_to_plrs(
|
|
||||||
session,
|
|
||||||
self.playlist_id,
|
|
||||||
[self.querylist_rows[a].playlistrow_id for a in row_group],
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
track_sequence.current
|
|
||||||
and playlist_row.id == track_sequence.current.playlistrow_id
|
|
||||||
):
|
|
||||||
# Don't move current track
|
|
||||||
continue
|
|
||||||
playlist_row.playlist_id = to_playlist_id
|
|
||||||
playlist_row.row_number = next_to_row
|
|
||||||
next_to_row += 1
|
|
||||||
self.refresh_data(session)
|
|
||||||
super().endRemoveRows()
|
|
||||||
# We need to remove gaps in row numbers after tracks have
|
|
||||||
# moved.
|
|
||||||
PlaylistRows.fixup_rownumbers(session, self.playlist_id)
|
|
||||||
self.refresh_data(session)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Reset of model must come after session has been closed
|
|
||||||
self.reset_track_sequence_row_numbers()
|
|
||||||
self.signals.end_reset_model_signal.emit(to_playlist_id)
|
|
||||||
|
|
||||||
def reset_track_sequence_row_numbers(self) -> None:
|
|
||||||
"""
|
|
||||||
Signal handler for when row ordering has changed.
|
|
||||||
|
|
||||||
Example: row 4 is marked as next. Row 2 is deleted. The PlaylistRows table will
|
|
||||||
be correctly updated with change of row number, but track_sequence.next will still
|
|
||||||
contain row_number==4. This function fixes up the track_sequence row numbers by
|
|
||||||
looking up the playlistrow_id and retrieving the row number from the database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{self}: reset_track_sequence_row_numbers()")
|
|
||||||
|
|
||||||
# Check the track_sequence.next, current and previous plrs and
|
|
||||||
# update the row number
|
|
||||||
with db.Session() as session:
|
|
||||||
for ts in [
|
|
||||||
track_sequence.next,
|
|
||||||
track_sequence.current,
|
|
||||||
track_sequence.previous,
|
|
||||||
]:
|
|
||||||
if ts:
|
|
||||||
ts.update_playlist_and_row(session)
|
|
||||||
|
|
||||||
def _reversed_contiguous_row_groups(
|
|
||||||
self, row_numbers: list[int]
|
|
||||||
) -> list[list[int]]:
|
|
||||||
"""
|
|
||||||
Take the list of row numbers and split into groups of contiguous rows. Return as a list
|
|
||||||
of lists with the highest row numbers first.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
input [2, 3, 4, 5, 7, 9, 10, 13, 17, 20, 21]
|
|
||||||
return: [[20, 21], [17], [13], [9, 10], [7], [2, 3, 4, 5]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.debug(f"{self}: _reversed_contiguous_row_groups({row_numbers=} called")
|
|
||||||
|
|
||||||
result: list[list[int]] = []
|
|
||||||
temp: list[int] = []
|
|
||||||
last_value = row_numbers[0] - 1
|
|
||||||
|
|
||||||
for idx in range(len(row_numbers)):
|
|
||||||
if row_numbers[idx] != last_value + 1:
|
|
||||||
result.append(temp)
|
|
||||||
temp = []
|
|
||||||
last_value = row_numbers[idx]
|
|
||||||
temp.append(last_value)
|
|
||||||
if temp:
|
|
||||||
result.append(temp)
|
|
||||||
result.reverse()
|
|
||||||
|
|
||||||
log.debug(f"{self}: _reversed_contiguous_row_groups() returned: {result=}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
|
def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
|
||||||
"""Standard function for view"""
|
"""Standard function for view"""
|
||||||
|
|
||||||
return len(self.querylist_rows)
|
return len(self.querylist_rows)
|
||||||
|
|
||||||
def selection_is_sortable(self, row_numbers: list[int]) -> bool:
|
def toggle_row_selection(self, row: int) -> None:
|
||||||
|
if row in self._selected_rows:
|
||||||
|
self._selected_rows.discard(row)
|
||||||
|
else:
|
||||||
|
self._selected_rows.add(row)
|
||||||
|
|
||||||
|
# Emit dataChanged for the entire row
|
||||||
|
top_left = self.index(row, 0)
|
||||||
|
bottom_right = self.index(row, self.columnCount() - 1)
|
||||||
|
self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.BackgroundRole])
|
||||||
|
|
||||||
|
def tooltip_role(self, row: int, column: int, rat: RowAndTrack) -> QVariant:
|
||||||
"""
|
"""
|
||||||
Return True if the selection is sortable. That means:
|
Return tooltip. Currently only used for last_played column.
|
||||||
- at least two rows selected
|
|
||||||
- selected rows are contiguous
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# at least two rows selected
|
if column != QueryCol.LAST_PLAYED.value:
|
||||||
if len(row_numbers) < 2:
|
return QVariant()
|
||||||
return False
|
|
||||||
|
|
||||||
# selected rows are contiguous
|
|
||||||
if sorted(row_numbers) != list(range(min(row_numbers), max(row_numbers) + 1)):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def setData(
|
|
||||||
self,
|
|
||||||
index: QModelIndex,
|
|
||||||
value: str | float,
|
|
||||||
role: int = Qt.ItemDataRole.EditRole,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Update model with edited data
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not index.isValid() or role != Qt.ItemDataRole.EditRole:
|
|
||||||
return False
|
|
||||||
|
|
||||||
row_number = index.row()
|
|
||||||
column = index.column()
|
|
||||||
|
|
||||||
with db.Session() as session:
|
with db.Session() as session:
|
||||||
playlist_row = session.get(
|
track_id = self.querylist_rows[row].track_id
|
||||||
PlaylistRows, self.querylist_rows[row_number].playlistrow_id
|
if not track_id:
|
||||||
)
|
return QVariant()
|
||||||
if not playlist_row:
|
playdates = Playdates.last_playdates(session, track_id)
|
||||||
log.error(
|
return QVariant(
|
||||||
f"{self}: Error saving data: {row_number=}, {column=}, {value=}"
|
"<br>".join(
|
||||||
|
[
|
||||||
|
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
|
||||||
|
for a in reversed(playdates)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
if column in [QueryCol.TITLE.value, QueryCol.ARTIST.value]:
|
|
||||||
track = session.get(Tracks, playlist_row.track_id)
|
|
||||||
if not track:
|
|
||||||
log.error(f"{self}: Error retreiving track: {playlist_row=}")
|
|
||||||
return False
|
|
||||||
if column == QueryCol.TITLE.value:
|
|
||||||
track.title = str(value)
|
|
||||||
elif column == QueryCol.ARTIST.value:
|
|
||||||
track.artist = str(value)
|
|
||||||
else:
|
|
||||||
log.error(f"{self}: Error updating track: {column=}, {value=}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# commit changes before refreshing data
|
|
||||||
session.commit()
|
|
||||||
self.refresh_row(session, row_number)
|
|
||||||
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, role])
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sort_by_artist(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by artist
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "artist")
|
|
||||||
|
|
||||||
def sort_by_attribute(self, row_numbers: list[int], attr_name: str) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by passed attribute name where 'attribute' is a
|
|
||||||
key in PlaylistRowData
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Create a subset of playlist_rows with the rows we are
|
|
||||||
# interested in
|
|
||||||
shortlist_rows = {k: self.querylist_rows[k] for k in row_numbers}
|
|
||||||
sorted_list = [
|
|
||||||
playlist_row.row_number
|
|
||||||
for playlist_row in sorted(
|
|
||||||
shortlist_rows.values(), key=attrgetter(attr_name)
|
|
||||||
)
|
)
|
||||||
]
|
|
||||||
self.move_rows(sorted_list, min(sorted_list))
|
|
||||||
|
|
||||||
def sort_by_duration(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by duration
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "duration")
|
|
||||||
|
|
||||||
def sort_by_lastplayed(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by lastplayed
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "lastplayed")
|
|
||||||
|
|
||||||
def sort_randomly(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows randomly
|
|
||||||
"""
|
|
||||||
|
|
||||||
shuffle(row_numbers)
|
|
||||||
self.move_rows(row_numbers, min(row_numbers))
|
|
||||||
|
|
||||||
def sort_by_title(self, row_numbers: list[int]) -> None:
|
|
||||||
"""
|
|
||||||
Sort selected rows by title
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.sort_by_attribute(row_numbers, "title")
|
|
||||||
|
|
||||||
|
|
||||||
class QuerylistProxyModel(QSortFilterProxyModel):
|
|
||||||
"""
|
|
||||||
For searching and filtering
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
# Search all columns
|
|
||||||
self.setFilterKeyColumn(-1)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<PlaylistProxyModel: sourceModel={self.sourceModel()}>"
|
|
||||||
|
|
||||||
def set_incremental_search(self, search_string: str) -> None:
|
|
||||||
"""
|
|
||||||
Update search pattern
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.setFilterRegularExpression(
|
|
||||||
QRegularExpression(
|
|
||||||
search_string, QRegularExpression.PatternOption.CaseInsensitiveOption
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def sourceModel(self) -> QuerylistModel:
|
|
||||||
"""
|
|
||||||
Override sourceModel to return correct type
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cast(QuerylistModel, super().sourceModel())
|
|
||||||
|
|||||||
@ -1,55 +1,28 @@
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
from typing import Any, Callable, cast, List, Optional, TYPE_CHECKING
|
from typing import cast, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
# PyQt imports
|
# PyQt imports
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
QAbstractItemModel,
|
|
||||||
QEvent,
|
|
||||||
QModelIndex,
|
|
||||||
QObject,
|
|
||||||
QItemSelection,
|
|
||||||
QSize,
|
|
||||||
Qt,
|
|
||||||
QTimer,
|
QTimer,
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import QAction, QDropEvent, QKeyEvent, QTextDocument
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemDelegate,
|
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QApplication,
|
|
||||||
QDoubleSpinBox,
|
|
||||||
QFrame,
|
|
||||||
QMenu,
|
|
||||||
QMessageBox,
|
|
||||||
QProxyStyle,
|
|
||||||
QStyle,
|
|
||||||
QStyledItemDelegate,
|
|
||||||
QStyleOption,
|
|
||||||
QStyleOptionViewItem,
|
|
||||||
QTableView,
|
QTableView,
|
||||||
QTableWidgetItem,
|
|
||||||
QTextEdit,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
import line_profiler
|
# import line_profiler
|
||||||
|
|
||||||
# App imports
|
# App imports
|
||||||
from audacity_controller import AudacityController
|
from audacity_controller import AudacityController
|
||||||
from classes import ApplicationError, Col, MusicMusterSignals, PlaylistStyle, TrackInfo
|
from classes import ApplicationError, MusicMusterSignals, PlaylistStyle
|
||||||
from config import Config
|
from config import Config
|
||||||
from dialogs import TrackSelectDialog
|
|
||||||
from helpers import (
|
from helpers import (
|
||||||
ask_yes_no,
|
|
||||||
ms_to_mmss,
|
|
||||||
show_OK,
|
|
||||||
show_warning,
|
show_warning,
|
||||||
)
|
)
|
||||||
from log import log
|
from log import log
|
||||||
from models import db, Settings
|
from models import db, Settings
|
||||||
from music_manager import track_sequence
|
from querylistmodel import QuerylistModel
|
||||||
from querylistmodel import QuerylistModel, QuerylistProxyModel
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from musicmuster import Window
|
from musicmuster import Window
|
||||||
@ -60,13 +33,13 @@ class QuerylistTab(QTableView):
|
|||||||
The querylist view
|
The querylist view
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, musicmuster: "Window", model: QuerylistProxyModel) -> None:
|
def __init__(self, musicmuster: "Window", model: QuerylistModel) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
# Save passed settings
|
# Save passed settings
|
||||||
self.musicmuster = musicmuster
|
self.musicmuster = musicmuster
|
||||||
|
|
||||||
self.playlist_id = model.sourceModel().playlist_id
|
self.playlist_id = model.playlist_id
|
||||||
|
|
||||||
# Set up widget
|
# Set up widget
|
||||||
self.setAlternatingRowColors(True)
|
self.setAlternatingRowColors(True)
|
||||||
@ -79,20 +52,17 @@ class QuerylistTab(QTableView):
|
|||||||
# here means we can click and drag to select rows.
|
# here means we can click and drag to select rows.
|
||||||
self.setDragEnabled(False)
|
self.setDragEnabled(False)
|
||||||
|
|
||||||
# Prepare for context menu
|
|
||||||
self.menu = QMenu()
|
|
||||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
||||||
self.customContextMenuRequested.connect(self._context_menu)
|
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self.signals = MusicMusterSignals()
|
self.signals = MusicMusterSignals()
|
||||||
self.signals.resize_rows_signal.connect(self.resize_rows)
|
self.signals.resize_rows_signal.connect(self.resize_rows)
|
||||||
self.signals.span_cells_signal.connect(self._span_cells)
|
|
||||||
|
|
||||||
# Selection model
|
# Selection model
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
|
||||||
|
# Enable item editing for checkboxes
|
||||||
|
self.clicked.connect(self.handle_row_click)
|
||||||
|
|
||||||
# Set up for Audacity
|
# Set up for Audacity
|
||||||
try:
|
try:
|
||||||
self.ac: Optional[AudacityController] = AudacityController()
|
self.ac: Optional[AudacityController] = AudacityController()
|
||||||
@ -122,114 +92,6 @@ class QuerylistTab(QTableView):
|
|||||||
|
|
||||||
# ########## Overridden class functions ##########
|
# ########## 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.get_base_model().update_track_times()
|
|
||||||
|
|
||||||
# Deselect edited line
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
@line_profiler.profile
|
|
||||||
def dropEvent(
|
|
||||||
self, event: Optional[QDropEvent], dummy_for_profiling: Optional[int] = None
|
|
||||||
) -> 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()
|
|
||||||
log.debug(
|
|
||||||
f"PlaylistTab.dropEvent(): {from_rows=}, {destination_index=}, {to_model_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 (
|
|
||||||
track_sequence.current
|
|
||||||
and to_model_row == 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):
|
def resizeRowToContents(self, row):
|
||||||
super().resizeRowToContents(row)
|
super().resizeRowToContents(row)
|
||||||
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
|
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
|
||||||
@ -240,214 +102,7 @@ class QuerylistTab(QTableView):
|
|||||||
hint = self.sizeHintForRow(row)
|
hint = self.sizeHintForRow(row)
|
||||||
header.resizeSection(row, hint)
|
header.resizeSection(row, hint)
|
||||||
|
|
||||||
def selectionChanged(
|
|
||||||
self, selected: QItemSelection, deselected: QItemSelection
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Toggle drag behaviour according to whether rows are selected
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_rows = self.get_selected_rows()
|
|
||||||
self.musicmuster.current.selected_rows = 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.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 ##########
|
# ########## 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(
|
|
||||||
parent=self.musicmuster,
|
|
||||||
session=session,
|
|
||||||
new_row_number=model_row_number,
|
|
||||||
base_model=self.get_base_model(),
|
|
||||||
add_to_header=True,
|
|
||||||
)
|
|
||||||
dlg.exec()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
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 track_sequence.current:
|
|
||||||
this_is_current_row = model_row_number == track_sequence.current.row_number
|
|
||||||
else:
|
|
||||||
this_is_current_row = False
|
|
||||||
if track_sequence.next:
|
|
||||||
this_is_next_row = model_row_number == 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:
|
def clear_selection(self) -> None:
|
||||||
"""Unselect all tracks and reset drag mode"""
|
"""Unselect all tracks and reset drag mode"""
|
||||||
|
|
||||||
@ -479,234 +134,16 @@ class QuerylistTab(QTableView):
|
|||||||
record.f_int = self.columnWidth(column_number)
|
record.f_int = self.columnWidth(column_number)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
def _context_menu(self, pos):
|
def handle_row_click(self, index):
|
||||||
"""Display right-click menu"""
|
self.model().toggle_row_selection(index.row())
|
||||||
|
self.clearSelection()
|
||||||
|
|
||||||
item = self.indexAt(pos)
|
def model(self) -> QuerylistModel:
|
||||||
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)
|
|
||||||
|
|
||||||
def current_track_started(self) -> None:
|
|
||||||
"""
|
|
||||||
Called when track starts playing
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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(self.selected_model_row_numbers())
|
|
||||||
self.clear_selection()
|
|
||||||
|
|
||||||
def get_base_model(self) -> QuerylistModel:
|
|
||||||
"""
|
|
||||||
Return the base model for this proxy model
|
|
||||||
"""
|
|
||||||
|
|
||||||
return cast(QuerylistModel, 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
|
||||||
result = sorted(
|
|
||||||
list(
|
|
||||||
set([self.model().mapToSource(a).row() for a in self.selectedIndexes()])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug(f"get_selected_rows() returned: {result=}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def hide_played_sections(self) -> None:
|
|
||||||
"""
|
|
||||||
Scroll played sections off screen
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.scroll_to_top(self.get_base_model().active_section_header())
|
|
||||||
|
|
||||||
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(self.musicmuster, "Track info", txt)
|
|
||||||
|
|
||||||
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) -> QuerylistProxyModel:
|
|
||||||
"""
|
"""
|
||||||
Override return type to keep mypy happy in this module
|
Override return type to keep mypy happy in this module
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return cast(QuerylistProxyModel, super().model())
|
return cast(QuerylistModel, 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:
|
def resize_rows(self, playlist_id: Optional[int] = None) -> None:
|
||||||
"""
|
"""
|
||||||
@ -731,85 +168,6 @@ class QuerylistTab(QTableView):
|
|||||||
# Start resizing from row 0, 10 rows at a time
|
# Start resizing from row 0, 10 rows at a time
|
||||||
QTimer.singleShot(0, lambda: resize_row(0, Config.RESIZE_ROW_CHUNK_SIZE))
|
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)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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:
|
def _set_column_widths(self) -> None:
|
||||||
"""Column widths from settings"""
|
"""Column widths from settings"""
|
||||||
|
|
||||||
@ -829,64 +187,7 @@ class QuerylistTab(QTableView):
|
|||||||
else:
|
else:
|
||||||
self.setColumnWidth(column_number, Config.DEFAULT_COLUMN_WIDTH)
|
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:
|
def tab_live(self) -> None:
|
||||||
"""
|
"""Noop for query tabs"""
|
||||||
Called when tab gets focus
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Update musicmuster
|
return
|
||||||
self.musicmuster.current.playlist_id = self.playlist_id
|
|
||||||
self.musicmuster.current.selected_rows = 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"""
|
|
||||||
|
|
||||||
track_sequence.set_next(None)
|
|
||||||
self.clear_selection()
|
|
||||||
self.signals.next_track_changed_signal.emit()
|
|
||||||
|
|||||||
@ -686,5 +686,5 @@ class Ui_MainWindow(object):
|
|||||||
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
self.actionImport_files.setText(_translate("MainWindow", "Import files..."))
|
||||||
self.actionOpenQuerylist.setText(_translate("MainWindow", "Open &querylist..."))
|
self.actionOpenQuerylist.setText(_translate("MainWindow", "Open &querylist..."))
|
||||||
self.actionManage_querylists.setText(_translate("MainWindow", "Manage querylists..."))
|
self.actionManage_querylists.setText(_translate("MainWindow", "Manage querylists..."))
|
||||||
from infotabs import InfoTabs
|
from infotabs import InfoTabs # type: ignore
|
||||||
from pyqtgraph import PlotWidget
|
from pyqtgraph import PlotWidget # type: ignore
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user