WIP V3: Add track to header row implemented

This commit is contained in:
Keith Edmunds 2023-10-31 20:09:45 +00:00
parent 9554336860
commit fedcfc3eea
5 changed files with 334 additions and 279 deletions

View File

@ -14,7 +14,8 @@ class MusicMusterSignals(QObject):
https://refactoring.guru/design-patterns/singleton/python/example#example-0
"""
add_track_to_header_signal = pyqtSignal(int, int, int)
add_track_to_playlist_signal = pyqtSignal(int, int, int, str)
enable_escape_signal = pyqtSignal(bool)
set_next_track_signal = pyqtSignal(int, int)
span_cells_signal = pyqtSignal(int, int, int, int)
add_track_to_playlist_signal = pyqtSignal(int, int, int, str)

175
app/dialogs.py Normal file
View File

@ -0,0 +1,175 @@
from PyQt6.QtCore import QEvent, Qt
from PyQt6.QtWidgets import QDialog, QListWidgetItem
from typing import Optional
import helpers
from datastructures import MusicMusterSignals
from dbconfig import scoped_session
from models import Settings, Tracks
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
session: scoped_session,
new_row_number: int,
playlist_id: int,
add_to_header: Optional[bool] = False,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.playlist_id = playlist_id
self.add_to_header = add_to_header
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not note and not track:
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
track_id = track.id
if self.add_to_header:
self.signals.add_track_to_header_signal.emit(
self.playlist_id, self.new_row_number, track_id
)
else:
self.signals.add_track_to_playlist_signal.emit(
self.playlist_id, self.new_row_number, track_id, note
)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{helpers.ms_to_mmss(track.duration)}] "
f"({helpers.get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
event.accept()
def keyPressEvent(self, event):
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({helpers.get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())

View File

@ -66,12 +66,12 @@ from dbconfig import (
import helpers
import icons_rc # noqa F401
import music
from dialogs import TrackSelectDialog
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from config import Config
from datastructures import MusicMusterSignals
from playlists import PlaylistTab
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_TrackSelect_ui import Ui_Dialog # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore
@ -1901,164 +1901,6 @@ class CartDialog(QDialog):
self.ui.lblPath.setText(self.path)
class TrackSelectDialog(QDialog):
"""Select track from database"""
def __init__(
self,
session: scoped_session,
new_row_number: int,
playlist_id: int,
*args,
**kwargs,
) -> None:
"""
Subclassed QDialog to manage track selection
"""
super().__init__(*args, **kwargs)
self.session = session
self.new_row_number = new_row_number
self.playlist_id = playlist_id
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.btnAdd.clicked.connect(self.add_selected)
self.ui.btnAddClose.clicked.connect(self.add_selected_and_close)
self.ui.btnClose.clicked.connect(self.close)
self.ui.matchList.itemDoubleClicked.connect(self.add_selected)
self.ui.matchList.itemSelectionChanged.connect(self.selection_changed)
self.ui.radioTitle.toggled.connect(self.title_artist_toggle)
self.ui.searchString.textEdited.connect(self.chars_typed)
self.track: Optional[Tracks] = None
self.signals = MusicMusterSignals()
record = Settings.get_int_settings(self.session, "dbdialog_width")
width = record.f_int or 800
record = Settings.get_int_settings(self.session, "dbdialog_height")
height = record.f_int or 600
self.resize(width, height)
def add_selected(self) -> None:
"""Handle Add button"""
track = None
if self.ui.matchList.selectedItems():
item = self.ui.matchList.currentItem()
if item:
track = item.data(Qt.ItemDataRole.UserRole)
note = self.ui.txtNote.text()
if not note and not track:
return
self.ui.txtNote.clear()
self.select_searchtext()
track_id = None
if track:
track_id = track.id
self.signals.add_track_to_playlist_signal.emit(
self.playlist_id, self.new_row_number, track_id, note
)
def add_selected_and_close(self) -> None:
"""Handle Add and Close button"""
self.add_selected()
self.accept()
def chars_typed(self, s: str) -> None:
"""Handle text typed in search box"""
self.ui.matchList.clear()
if len(s) > 0:
if self.ui.radioTitle.isChecked():
matches = Tracks.search_titles(self.session, "%" + s)
else:
matches = Tracks.search_artists(self.session, "%" + s)
if matches:
for track in matches:
last_played = None
last_playdate = max(
track.playdates, key=lambda p: p.lastplayed, default=None
)
if last_playdate:
last_played = last_playdate.lastplayed
t = QListWidgetItem()
track_text = (
f"{track.title} - {track.artist} "
f"[{helpers.ms_to_mmss(track.duration)}] "
f"({helpers.get_relative_date(last_played)})"
)
t.setText(track_text)
t.setData(Qt.ItemDataRole.UserRole, track)
self.ui.matchList.addItem(t)
def closeEvent(self, event: Optional[QEvent]) -> None:
"""
Override close and save dialog coordinates
"""
if not event:
return
record = Settings.get_int_settings(self.session, "dbdialog_height")
if record.f_int != self.height():
record.update(self.session, {"f_int": self.height()})
record = Settings.get_int_settings(self.session, "dbdialog_width")
if record.f_int != self.width():
record.update(self.session, {"f_int": self.width()})
event.accept()
def keyPressEvent(self, event):
"""
Clear selection on ESC if there is one
"""
if event.key() == Qt.Key.Key_Escape:
if self.ui.matchList.selectedItems():
self.ui.matchList.clearSelection()
return
super(TrackSelectDialog, self).keyPressEvent(event)
def select_searchtext(self) -> None:
"""Select the searchbox"""
self.ui.searchString.selectAll()
self.ui.searchString.setFocus()
def selection_changed(self) -> None:
"""Display selected track path in dialog box"""
if not self.ui.matchList.selectedItems():
return
item = self.ui.matchList.currentItem()
track = item.data(Qt.ItemDataRole.UserRole)
last_playdate = max(track.playdates, key=lambda p: p.lastplayed, default=None)
if last_playdate:
last_played = last_playdate.lastplayed
else:
last_played = None
path_text = f"{track.path} ({helpers.get_relative_date(last_played)})"
self.ui.dbPath.setText(path_text)
def title_artist_toggle(self) -> None:
"""
Handle switching between searching for artists and searching for
titles
"""
# Logic is handled already in chars_typed(), so just call that.
self.chars_typed(self.ui.searchString.text())
class DownloadCSV(QDialog):
def __init__(self, parent=None):
super().__init__()

View File

@ -21,6 +21,7 @@ from dbconfig import scoped_session, Session
from helpers import (
file_is_unreadable,
)
from log import log
from models import PlaylistRows, Tracks
@ -104,6 +105,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals = MusicMusterSignals()
self.signals.add_track_to_playlist_signal.connect(self.add_track)
self.signals.add_track_to_header_signal.connect(self.add_track_to_header)
with Session() as session:
self.refresh_data(session)
@ -138,6 +140,49 @@ class PlaylistModel(QAbstractTableModel):
# No track, no note, no point
return
def add_track_to_header(
self,
playlist_id: int,
row_number: int,
track_id: int,
) -> None:
"""
Add track to existing header row if it's for our playlist
"""
# Ignore if it's not for us
if playlist_id != self.playlist_id:
return
# Get existing row
try:
prd = self.playlist_rows[row_number]
except KeyError:
log.error(
f"KeyError in PlaylistModel:add_track_to_header ({playlist_id=}, "
f"{row_number=}, {track_id=}, {len(self.playlist_rows)=}"
)
return
if prd.path:
log.error(
f"Error in PlaylistModel:add_track_to_header ({prd=}, "
"Header row already has track associated"
)
return
with Session() as session:
plr = session.get(PlaylistRows, prd.plrid)
if plr:
# Add track to PlaylistRows
plr.track_id = track_id
# Reset header row spanning
self.signals.span_cells_signal.emit(
row_number, HEADER_NOTES_COLUMN, 1, 1
)
# Update local copy
self.refresh_row(session, row_number)
# Repaint row
self.invalidate_row(row_number)
def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush:
"""Return background setting"""
@ -321,6 +366,13 @@ class PlaylistModel(QAbstractTableModel):
return QVariant()
def is_header_row(self, row_number: int) -> bool:
"""
Return True if row is a header row, else False
"""
return self.playlist_rows[row_number].path == ""
def insert_header_row(self, row_number: Optional[int], text: str) -> None:
"""
Insert a header row.

View File

@ -7,7 +7,7 @@ import threading
import obsws_python as obs # type: ignore
from datetime import datetime, timedelta
from typing import Any, cast, List, Optional, Tuple, TYPE_CHECKING
from typing import Any, Callable, cast, List, Optional, Tuple, TYPE_CHECKING
from PyQt6.QtCore import (
QEvent,
@ -22,7 +22,7 @@ from PyQt6.QtWidgets import (
QAbstractItemView,
QApplication,
QHeaderView,
# QMenu,
QMenu,
QMessageBox,
QPlainTextEdit,
QStyledItemDelegate,
@ -37,6 +37,7 @@ from PyQt6.QtWidgets import (
from datastructures import MusicMusterSignals
from dbconfig import Session, scoped_session
from dialogs import TrackSelectDialog
from config import Config
from helpers import (
ask_yes_no,
@ -48,11 +49,11 @@ from helpers import (
set_track_metadata,
)
from log import log
from models import Playlists, PlaylistRows, Settings, Tracks, NoteColours
from playlistmodel import PlaylistModel
from models import PlaylistRows, Settings, Tracks, NoteColours
if TYPE_CHECKING:
from musicmuster import Window, MusicMusterSignals
from musicmuster import Window
from playlistmodel import PlaylistModel
HEADER_NOTES_COLUMN = 2
@ -78,7 +79,8 @@ class EscapeDelegate(QStyledItemDelegate):
Intercept createEditor call and make row just a little bit taller
"""
signals.enable_escape_signal.emit(False)
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):
@ -111,7 +113,7 @@ class EscapeDelegate(QStyledItemDelegate):
return True
elif key_event.key() == Qt.Key.Key_Escape:
discard_edits = QMessageBox.question(
self.parent(), "Abandon edit", "Discard changes?"
cast(QWidget, self), "Abandon edit", "Discard changes?"
)
if discard_edits == QMessageBox.StandardButton.Yes:
self.closeEditor.emit(editor)
@ -134,8 +136,7 @@ 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. This may not always work depending on global
style - for instance I think it won't work on OSX.
we're hovering over.
"""
if (
element == QStyle.PrimitiveElement.PE_IndicatorItemViewItemDrop
@ -178,9 +179,9 @@ class PlaylistTab(QTableView):
# rows selected
self.setDragEnabled(True)
# Prepare for context menu
# self.menu = QMenu()
# self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# self.customContextMenuRequested.connect(self._context_menu)
self.menu = QMenu()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._context_menu)
# Connect signals
# This dancing is to satisfy mypy
@ -291,27 +292,27 @@ class PlaylistTab(QTableView):
# self.hide_or_show_played_tracks()
# def _add_context_menu(
# self,
# text: str,
# action: Callable,
# disabled: bool = False,
# parent_menu: Optional[QMenu] = None,
# ) -> Optional[QAction]:
# """
# Add item to self.menu
# """
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
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)
menu_item = parent_menu.addAction(text)
if not menu_item:
return None
menu_item.setDisabled(disabled)
menu_item.triggered.connect(action)
# return menu_item
return menu_item
# def mouseReleaseEvent(self, event):
# """
@ -1033,106 +1034,90 @@ class PlaylistTab(QTableView):
"""Add a track to a section header making it a normal track row"""
with Session() as session:
# Add track to playlist row
plr = self._get_row_plr(session, row_number)
if not plr:
return
dlg = TrackSelectDialog(
session=session,
new_row_number=row_number,
playlist_id=self.playlist_id,
add_to_header=True,
)
dlg.exec()
# Don't add track if there's already a track there
if plr.track_id is not None:
return
def _build_context_menu(self, item: QTableWidgetItem) -> None:
"""Used to process context (right-click) menu, which is defined here"""
# Get track
track = self.musicmuster.get_one_track(session)
if not track:
return
plr.track_id = track.id
self.menu.clear()
row_number = item.row()
# track_id = self._get_row_track_id(row_number)
# track_row = bool(track_id)
header_row = False
model = cast(PlaylistModel, self.model())
if model:
# Reset row span
self.setSpan(row_number, HEADER_NOTES_COLUMN, 1, 1)
header_row = model.is_header_row(row_number)
# current = row_number == self._get_current_track_row_number()
# next_row = row_number == self._get_next_track_row_number()
# Update attributes of row
self._update_row_track_info(session, row_number, track)
self._set_row_bold(row_number)
self._set_row_colour_default(row_number)
self._set_row_note_text(session, row_number, plr.note)
self.clear_selection()
self.save_playlist(session)
# Update times once display updated
self._update_start_end_times(session)
# # Play with mplayer
# if track_row and not current:
# self._add_context_menu(
# "Play with mplayer", lambda: self._mplayer_play(row_number)
# )
# def _build_context_menu(self, item: QTableWidgetItem) -> None:
# """Used to process context (right-click) menu, which is defined here"""
# # Paste
# self._add_context_menu(
# "Paste",
# lambda: self.musicmuster.paste_rows(),
# self.musicmuster.selected_plrs is None,
# )
# self.menu.clear()
# row_number = item.row()
# track_id = self._get_row_track_id(row_number)
# track_row = bool(track_id)
# header_row = not track_row
# current = row_number == self._get_current_track_row_number()
# next_row = row_number == self._get_next_track_row_number()
# # Open in Audacity
# if track_row and not current:
# self._add_context_menu(
# "Open in Audacity", lambda: self._open_in_audacity(row_number)
# )
# # Play with mplayer
# if track_row and not current:
# self._add_context_menu(
# "Play with mplayer", lambda: self._mplayer_play(row_number)
# )
# # Rescan
# if track_row and not current:
# self._add_context_menu(
# "Rescan track", lambda: self._rescan(row_number, track_id)
# )
# # Paste
# self._add_context_menu(
# "Paste",
# lambda: self.musicmuster.paste_rows(),
# self.musicmuster.selected_plrs is None,
# )
# # ----------------------
self.menu.addSeparator()
# # Open in Audacity
# if track_row and not current:
# self._add_context_menu(
# "Open in Audacity", lambda: self._open_in_audacity(row_number)
# )
# # Remove row
# if not current and not next_row:
# self._add_context_menu("Delete row", self._delete_rows)
# # Rescan
# if track_row and not current:
# self._add_context_menu(
# "Rescan track", lambda: self._rescan(row_number, track_id)
# )
# # Move to playlist
# if not current and not next_row:
# self._add_context_menu(
# "Move to playlist...", self.musicmuster.move_selected
# )
# # ----------------------
# self.menu.addSeparator()
# # ----------------------
# self.menu.addSeparator()
# # Remove row
# if not current and not next_row:
# self._add_context_menu("Delete row", self._delete_rows)
# # Remove track from row
# if track_row and not current and not next_row:
# self._add_context_menu(
# "Remove track from row", lambda: self._remove_track(row_number)
# )
# # Move to playlist
# if not current and not next_row:
# self._add_context_menu(
# "Move to playlist...", self.musicmuster.move_selected
# )
# 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(row_number))
# # ----------------------
# self.menu.addSeparator()
# # Mark unplayed
# if self._get_row_userdata(row_number, self.PLAYED):
# self._add_context_menu("Mark unplayed", self._mark_unplayed)
# # Remove track from row
# if track_row and not current and not next_row:
# self._add_context_menu(
# "Remove track from row", lambda: self._remove_track(row_number)
# )
# # Unmark as next
# if next_row:
# self._add_context_menu("Unmark as next track", self.clear_next)
# # 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(row_number))
# # Mark unplayed
# if self._get_row_userdata(row_number, self.PLAYED):
# self._add_context_menu("Mark unplayed", self._mark_unplayed)
# # Unmark as next
# if next_row:
# self._add_context_menu("Unmark as next track", self.clear_next)
# # ----------------------
# self.menu.addSeparator()
# # ----------------------
self.menu.addSeparator()
# # Sort
# sort_menu = self.menu.addMenu("Sort")
@ -1198,12 +1183,12 @@ class PlaylistTab(QTableView):
record = Settings.get_int_settings(session, attr_name)
record.f_int = self.columnWidth(column_number)
# def _context_menu(self, pos):
# """Display right-click menu"""
def _context_menu(self, pos):
"""Display right-click menu"""
# item = self.itemAt(pos)
# self._build_context_menu(item)
# self.menu.exec(self.mapToGlobal(pos))
item = self.indexAt(pos)
self._build_context_menu(item)
self.menu.exec(self.mapToGlobal(pos))
def _copy_path(self, row_number: int) -> None:
"""