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 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) enable_escape_signal = pyqtSignal(bool)
set_next_track_signal = pyqtSignal(int, int) set_next_track_signal = pyqtSignal(int, int)
span_cells_signal = pyqtSignal(int, int, 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 helpers
import icons_rc # noqa F401 import icons_rc # noqa F401
import music import music
from dialogs import TrackSelectDialog
from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import Base, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from config import Config from config import Config
from datastructures import MusicMusterSignals from datastructures import MusicMusterSignals
from playlists import PlaylistTab from playlists import PlaylistTab
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore 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.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # 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) 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): class DownloadCSV(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__() super().__init__()

View File

@ -21,6 +21,7 @@ from dbconfig import scoped_session, Session
from helpers import ( from helpers import (
file_is_unreadable, file_is_unreadable,
) )
from log import log
from models import PlaylistRows, Tracks from models import PlaylistRows, Tracks
@ -104,6 +105,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.signals.add_track_to_playlist_signal.connect(self.add_track) 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: with Session() as session:
self.refresh_data(session) self.refresh_data(session)
@ -138,6 +140,49 @@ class PlaylistModel(QAbstractTableModel):
# No track, no note, no point # No track, no note, no point
return 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: def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush:
"""Return background setting""" """Return background setting"""
@ -321,6 +366,13 @@ class PlaylistModel(QAbstractTableModel):
return QVariant() 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: def insert_header_row(self, row_number: Optional[int], text: str) -> None:
""" """
Insert a header row. Insert a header row.

View File

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