From d5871fe77f9b7395bbae955b09fa7872c87101b8 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Tue, 14 Nov 2023 23:45:47 +0000 Subject: [PATCH] WIP V3: context menu started Sort by title implemented --- app/playlistmodel.py | 136 ++++++++++++++++++++++++++-- app/playlists.py | 210 +++++++++++++++---------------------------- 2 files changed, 203 insertions(+), 143 deletions(-) diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 0caab68..e3f562e 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -17,7 +17,13 @@ from PyQt6.QtGui import ( from classes import track_sequence, MusicMusterSignals, PlaylistTrack from config import Config from dbconfig import scoped_session, Session -from helpers import file_is_unreadable, get_embedded_time, ms_to_mmss +from helpers import ( + file_is_unreadable, + get_embedded_time, + open_in_audacity, + ms_to_mmss, + set_track_metadata, +) from log import log from models import Playdates, PlaylistRows, Tracks @@ -57,6 +63,7 @@ class PlaylistRowData: self.plrid: int = plr.id self.plr_rownum: int = plr.plr_rownum self.note: str = plr.note + self.track_id = plr.track_id if plr.track: self.start_gap = plr.track.start_gap self.title = plr.track.title @@ -328,6 +335,14 @@ class PlaylistModel(QAbstractTableModel): # Fall through to no-op return QVariant() + def delete_rows(self, row_numbers: List[int]) -> None: + """ + Delete passed rows from model + """ + + # TODO + print(f"Delete rows {row_numbers=}") + def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant: """ Return text for display @@ -517,8 +532,9 @@ class PlaylistModel(QAbstractTableModel): section_end_time = track_sequence.now.end_time + timedelta( milliseconds=duration ) - end_time_str = ", section end time " + section_end_time.strftime( - Config.TRACK_TIME_FORMAT + end_time_str = ( + ", section end time " + + section_end_time.strftime(Config.TRACK_TIME_FORMAT) ) stripped_note = prd.note[:-1].strip() if stripped_note: @@ -547,6 +563,13 @@ class PlaylistModel(QAbstractTableModel): return self.playlist_rows[row_number].path == "" + def is_unplayed_row(self, row_number: int) -> bool: + """ + Return True if row is an unplayed track row, else False + """ + + return self.playlist_rows[row_number].played + def insert_header_row(self, row_number: Optional[int], text: str) -> None: """ Insert a header row. @@ -624,6 +647,14 @@ class PlaylistModel(QAbstractTableModel): for modified_row in modified_rows: self.invalidate_row(modified_row) + def mark_unplayed(self, row_number: int) -> None: + """ + Mark row as unplayed + """ + + self.playlist_rows[row_number].played = False + self.invalidate_row(row_number) + def move_rows(self, from_rows: List[int], to_row_number: int) -> None: """ Move the playlist rows given to to_row and below. @@ -677,6 +708,15 @@ class PlaylistModel(QAbstractTableModel): # Update display self.invalidate_rows(list(row_map.keys())) + def open_in_audacity(self, row_number: int) -> None: + """ + Open track at passed row number in Audacity + """ + + path = self.playlist_rows[row_number].path + if path: + open_in_audacity(path) + def previous_track_ended(self) -> None: """ Notification from musicmuster that the previous track has ended. @@ -718,16 +758,68 @@ class PlaylistModel(QAbstractTableModel): p = PlaylistRows.deep_row(session, self.playlist_id, row_number) self.playlist_rows[row_number] = PlaylistRowData(p) + def remove_track(self, row_number: int) -> None: + """ + Remove track from row + """ + + # TODO + print(f"remove_track({row_number=})") + + def rescan_track(self, row_number: int) -> None: + """ + Rescan track at passed row number + """ + + track_id = self.playlist_rows[row_number].track_id + if track_id: + with Session() as session: + track = session.get(Tracks, track_id) + set_track_metadata(track) + self.refresh_row(session, row_number) + self.invalidate_row(row_number) + def rowCount(self, index: QModelIndex = QModelIndex()) -> int: """Standard function for view""" return len(self.playlist_rows) - def set_next_row(self, row_number: int) -> None: + def selection_is_sortable(self, row_numbers: List[int]) -> bool: """ - Set row_number as next track + Return True if the selection is sortable. That means: + - at least two rows selected + - selected rows are contiguous + - selected rows do not include any header rows """ + # at least two rows selected + if len(row_numbers) < 2: + return False + + # selected rows are contiguous + if sorted(row_numbers) != list(range(min(row_numbers), max(row_numbers) + 1)): + return False + + # selected rows do not include any header rows + for row_number in row_numbers: + if self.is_header_row(row_number): + return False + + return True + + def set_next_row(self, row_number: Optional[int]) -> None: + """ + Set row_number as next track. If row_number is None, clear next track. + """ + + if row_number is None: + next_row_was = track_sequence.next.plr_rownum + if next_row_was is None: + return + track_sequence.next = PlaylistTrack() + self.invalidate_row(next_row_was) + return + # Update playing_trtack with Session() as session: track_sequence.next = PlaylistTrack() @@ -800,6 +892,40 @@ class PlaylistModel(QAbstractTableModel): return False + def sort_by_artist(self, row_numbers: List[int]) -> None: + """ + Sort selected rows by artist + """ + + pass + + def sort_by_duration(self, row_numbers: List[int]) -> None: + """ + Sort selected rows by duration + """ + + pass + + def sort_by_lastplayed(self, row_numbers: List[int]) -> None: + """ + Sort selected rows by lastplayed + """ + + pass + + def sort_by_title(self, row_numbers: List[int]) -> None: + """ + Sort selected rows by title + """ + + # Create a subset of playlist_rows with the rows we are + # interested in + shortlist_rows = {k: self.playlist_rows[k] for k in row_numbers} + sorted_list = [ + k for k, v in sorted(shortlist_rows.items(), key=lambda item: item[1].title) + ] + self.move_rows(sorted_list, min(sorted_list)) + def supportedDropActions(self) -> Qt.DropAction: return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction diff --git a/app/playlists.py b/app/playlists.py index 218074d..327e96e 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -37,7 +37,7 @@ from PyQt6.QtWidgets import ( from dbconfig import Session, scoped_session from dialogs import TrackSelectDialog -from classes import MusicMusterSignals +from classes import MusicMusterSignals, track_sequence from config import Config from helpers import ( ask_yes_no, @@ -1058,114 +1058,94 @@ class PlaylistTab(QTableView): """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_row = bool(track_id) - 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() - # next_row = row_number == self._get_next_track_row_number() + if not model: + return - # # Play with mplayer - # if track_row and not current: - # self._add_context_menu( - # "Play with mplayer", lambda: self._mplayer_play(row_number) - # ) + row_number = item.row() + header_row = model.is_header_row(row_number) + track_row = not header_row + current_row = row_number == track_sequence.now.plr_rownum + next_row = row_number == track_sequence.next.plr_rownum - # # Paste - # self._add_context_menu( - # "Paste", - # lambda: self.musicmuster.paste_rows(), - # self.musicmuster.selected_plrs is None, - # ) + # Open in Audacity + if track_row and not current_row: + self._add_context_menu( + "Open in Audacity", lambda: model.open_in_audacity(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) - # ) + # Rescan + if track_row and not current_row: + self._add_context_menu("Rescan track", lambda: self._rescan(row_number)) - # # Rescan - # if track_row and not current: - # self._add_context_menu( - # "Rescan track", lambda: self._rescan(row_number, track_id) - # ) - - # # ---------------------- + # ---------------------- self.menu.addSeparator() - # # Remove row - # if not current and not next_row: - # self._add_context_menu("Delete row", self._delete_rows) + # Delete row + if not current_row and not next_row: + self._add_context_menu( + "Delete row", lambda: model.delete_rows(self._get_selected_rows()) + ) - # # Move to playlist - # if not current and not next_row: - # self._add_context_menu( - # "Move to playlist...", self.musicmuster.move_selected - # ) - - # # ---------------------- - # self.menu.addSeparator() - - # # 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) - # ) + # Remove track from row + if track_row and not current_row and not next_row: + self._add_context_menu( + "Remove track from row", lambda: model.remove_track(row_number) + ) # Add track to section header (ie, make this a track row) + # TODO 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._add_context_menu("Add a track", lambda: print("Add a track")) # # ---------------------- self.menu.addSeparator() - # # Sort - # sort_menu = self.menu.addMenu("Sort") - # self._add_context_menu( - # "by title", lambda: self._sort_selection(TITLE), parent_menu=sort_menu - # ) - # self._add_context_menu( - # "by artist", lambda: self._sort_selection(ARTIST), parent_menu=sort_menu - # ) - # self._add_context_menu( - # "by duration", lambda: self._sort_selection(DURATION), parent_menu=sort_menu - # ) - # self._add_context_menu( - # "by last played", - # lambda: self._sort_selection(LASTPLAYED), - # parent_menu=sort_menu, - # ) - # if sort_menu: - # sort_menu.setEnabled(self._sortable()) - # self._add_context_menu("Undo sort", self._sort_undo, not bool(self.sort_undo)) + # Mark unplayed + if track_row and model.is_unplayed_row(row_number): + self._add_context_menu("Mark unplayed", lambda: model.mark_unplayed(row_number)) - # # Build submenu + # Unmark as next + if next_row: + self._add_context_menu("Unmark as next track", lambda: model.set_next_row(None)) - # # ---------------------- - # self.menu.addSeparator() + # ---------------------- + self.menu.addSeparator() - # # Info - # if track_row: - # self._add_context_menu("Info", lambda: self._info_row(track_id)) + # Sort + sort_menu = self.menu.addMenu("Sort") + self._add_context_menu( + "by title", + lambda: model.sort_by_title(self._get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by artist", + lambda: model.sort_by_artist(self._get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by duration", + lambda: model.sort_by_duration(self._get_selected_rows()), + parent_menu=sort_menu, + ) + self._add_context_menu( + "by last played", + lambda: model.sort_by_lastplayed(self._get_selected_rows()), + parent_menu=sort_menu, + ) + if sort_menu: + sort_menu.setEnabled(model.selection_is_sortable(self._get_selected_rows())) + self._add_context_menu("Undo sort", self._sort_undo, not bool(self.sort_undo)) - # # Track path - # if track_row: - # self._add_context_menu( - # "Copy track path", lambda: self._copy_path(row_number) - # ) + # Info TODO + if track_row: + self._add_context_menu("Info", lambda: print("Track info")) - # # return super(PlaylistTab, self).eventFilter(source, event) + # Track path TODO + if track_row: + self._add_context_menu( + "Copy track path", lambda: print("Track path")) def _calculate_end_time( self, start: Optional[datetime], duration: int @@ -1739,30 +1719,11 @@ class PlaylistTab(QTableView): for i in reversed(sorted(source_row_numbers)): self.removeRow(i) - def _rescan(self, row_number: int, track_id: int) -> None: + def _rescan(self, row_number: int) -> None: """Rescan track""" - with Session() as session: - track = session.get(Tracks, track_id) - if track: - if file_is_unreadable(track.path): - self._set_row_colour_unreadable(row_number) - else: - self._set_row_colour_default(row_number) - set_track_metadata(track) - self._update_row_track_info(session, row_number, track) - else: - _ = self._set_row_track_id(row_number, 0) - note_text = self._get_row_note(row_number) - if note_text is None: - note_text = "" - else: - note_text += f"{track_id=} not found" - self._set_row_header_text(session, row_number, note_text) - log.error(f"playlists._rescan({track_id=}): " "Track not found") - self._set_row_colour_unreadable(row_number) - - self._update_start_end_times(session) + model = cast(PlaylistModel, self.model()) + model.rescan_track(row_number) self.clear_selection() def _reset_next(self, old_plrid: int, new_plrid: int) -> None: @@ -2299,33 +2260,6 @@ class PlaylistTab(QTableView): return item - def _sortable(self) -> bool: - """ - Return True if the selection is sortable. That means: - - at least two rows selected - - selected rows are contiguous - - selected rows do not include any header rows - """ - - selectionModel = self.selectionModel() - if not selectionModel: - return False - source_rows = selectionModel.selectedRows() - if len(source_rows) < 2: - return False - - sorted_source_rows = sorted([a.row() for a in source_rows]) - if sorted_source_rows != list( - range(min(sorted_source_rows), max(sorted_source_rows) + 1) - ): - return False - - for row in sorted_source_rows: - if self._get_row_track_id(row) == 0: - return False - - return True - def _sort_selection(self, sort_column: int) -> None: """ Algorithm: