from PyQt5 import QtCore from PyQt5.QtCore import Qt from PyQt5.Qt import QFont from PyQt5.QtGui import QColor, QDropEvent from PyQt5 import QtWidgets from PyQt5.QtWidgets import ( QAbstractItemView, QMenu, QMessageBox, QTableWidget, QTableWidgetItem, ) import helpers import os from config import Config from datetime import datetime, timedelta from log import DEBUG, ERROR from model import Notes, Playlists, PlaylistTracks, Session, Settings, Tracks class Playlist(QTableWidget): # Column names COL_INDEX = 0 COL_MSS = 1 COL_NOTE = 2 COL_TITLE = 2 COL_ARTIST = 3 COL_DURATION = 4 COL_START_TIME = 5 COL_PATH = 6 NOTE_COL_SPAN = 3 NOTE_ROW_SPAN = 1 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.master_process = self.parent() self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.setAlternatingRowColors(True) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.setRowCount(0) self.setColumnCount(7) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(0, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(1, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(2, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(3, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(4, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(5, item) item = QtWidgets.QTableWidgetItem() self.setHorizontalHeaderItem(6, item) self.horizontalHeader().setMinimumSectionSize(0) self._set_column_widths() self.setHorizontalHeaderLabels(["ID", "Lead", "Title", "Artist", "Len", "Start", "Path"]) self.setDragEnabled(True) self.setAcceptDrops(True) self.viewport().setAcceptDrops(True) self.setDragDropOverwriteMode(False) self.setDropIndicatorShown(True) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setDragDropMode(QAbstractItemView.InternalMove) # This property holds how the widget shows a context menu self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) # This signal is emitted when the widget's contextMenuPolicy is # Qt::CustomContextMenu, and the user has requested a context # menu on the widget. self.customContextMenuRequested.connect(self._context_menu) self.viewport().installEventFilter(self) self.current_track_start_time = None self.played_tracks = [] # ########## Events ########## def closeEvent(self, event): "Save column widths" for column in range(self.columnCount()): width = self.columnWidth(column) name = f"playlist_col_{str(column)}_width" with Session() as session: record = Settings.get_int(session, name) if record.f_int != self.columnWidth(column): record.update(session, {'f_int': width}) event.accept() def dropEvent(self, event: QDropEvent): if not event.isAccepted() and event.source() == self: drop_row = self._drop_on(event) rows = sorted(set(item.row() for item in self.selectedItems())) rows_to_move = [ [QTableWidgetItem(self.item(row_index, column_index)) for column_index in range(self.columnCount())] for row_index in rows ] for row_index in reversed(rows): self.removeRow(row_index) if row_index < drop_row: drop_row -= 1 for row_index, data in enumerate(rows_to_move): row_index += drop_row self.insertRow(row_index) for column_index, column_data in enumerate(data): self.setItem(row_index, column_index, column_data) event.accept() # We don't want rows to be selected after move # for row_index in range(len(rows_to_move)): # for column_index in range(self.columnCount()): # self.item(drop_row + row_index, # column_index).setSelected(True) # The above doesn't handle column spans, which we use in note # rows. Check and fix: for row in range(drop_row, drop_row + len(rows_to_move)): if row in self._meta_get_notes(): self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) super().dropEvent(event) DEBUG( "playlist.dropEvent(): " f"Moved row(s) {rows} to become row {drop_row}" ) with Session() as session: self._save_playlist(session) self._repaint() def eventFilter(self, source, event): "Used to process context (right-click) menu" if(event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504 event.buttons() == QtCore.Qt.RightButton and # noqa W504 source is self.viewport()): item = self.itemAt(event.pos()) if item is not None: row = item.row() DEBUG(f"playlist.eventFilter(): Right-click on row {row}") self.menu = QMenu(self) if row not in self._meta_get_notes(): act_setnext = self.menu.addAction("Set next") act_setnext.triggered.connect(lambda: self._set_next(row)) self.menu.addSeparator() act_delete = self.menu.addAction('Delete') act_delete.triggered.connect(lambda: self._delete_row(row)) return super(Playlist, self).eventFilter(source, event) # ########## Externally called functions ########## def add_note(self, session, note, repaint=True): """ Add note to playlist If a row is selected, add note above. Otherwise, add to end of playlist. """ if self.selectionModel().hasSelection(): row = self.currentRow() else: row = self.rowCount() DEBUG(f"playlist.add_note(): row={row}") # Does note end with a time? start_time = None try: start_time = datetime.strptime(note.note[-9:], " %H:%M:%S").time() DEBUG(f"Note contains valid time={start_time}") except ValueError: DEBUG( f"Note on row {row} ('{note.note}') " "does not contain valid time" ) self.insertRow(row) item = QTableWidgetItem(str(note.id)) self.setItem(row, self.COL_INDEX, item) titleitem = QTableWidgetItem(note.note) self.setItem(row, self.COL_NOTE, titleitem) self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) # Add start times or empty items as background # colour won't be set for columns without items self._set_row_time(row, start_time) item = QTableWidgetItem() self.setItem(row, self.COL_PATH, item) self._meta_set_note(row) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if repaint: self._save_playlist(session) self._repaint(clear_selection=False) def add_to_playlist(self, session, data, repaint=True): """ Add data to playlist. Data may be either a Tracks object or a Notes object. """ DEBUG(f"playlists.add_to_playlist(session={session}, data={data})") if isinstance(data, Tracks): self.add_track(session, data, repaint=repaint) elif isinstance(data, Notes): self.add_note(session, data, repaint=repaint) def add_track(self, session, track, repaint=True): """ Add track to playlist If a row is selected, add track above. Otherwise, add to end of playlist. """ if self.selectionModel().hasSelection(): row = self.currentRow() else: row = self.rowCount() DEBUG(f"playlists.add_track(track={track}), row={row}") # We need to add ourself to the session us_in_db = session.query(Playlists).filter( Playlists.id == self.id).one() us_in_db.add_track(session, track, row) self.insertRow(row) item = QTableWidgetItem(str(track.id)) self.setItem(row, self.COL_INDEX, item) item = QTableWidgetItem(str(track.start_gap)) self.setItem(row, self.COL_MSS, item) titleitem = QTableWidgetItem(track.title) self.setItem(row, self.COL_TITLE, titleitem) item = QTableWidgetItem(track.artist) self.setItem(row, self.COL_ARTIST, item) item = QTableWidgetItem(helpers.ms_to_mmss(track.duration)) self.setItem(row, self.COL_DURATION, item) item = QTableWidgetItem(track.path) self.setItem(row, self.COL_PATH, item) # Add empty start time for now as background # colour won't be set for columns without items item = QTableWidgetItem() self.setItem(row, self.COL_START_TIME, item) # Scroll to new row self.scrollToItem(titleitem, QAbstractItemView.PositionAtCenter) if repaint: self._save_playlist(session) self._repaint(clear_selection=False) def clear_current(self): "Clear current track" self._meta_clear_current() self._repaint(save_playlist=False) def clear_next(self): "Clear next track" self._meta_clear_next() self._repaint(save_playlist=False) def get_next_track_id(self): "Return next track id" next_row = self._meta_get_next() return self._get_row_id(next_row) def get_selected_row(self): "Return row number of first selected row, or None if none selected" if not self.selectionModel().hasSelection(): return None else: return self.selectionModel().selectedRows()[0].row() def get_selected_rows_and_tracks(self): "Return a list of selected (rows, track_id) tuples" if not self.selectionModel().hasSelection(): return None result = [] for row in [r.row() for r in self.selectionModel().selectedRows()]: track_id = self._get_row_id(row) result.append((row, track_id)) return result def remove_rows(self, rows): "Remove rows passed in rows list" # Row number will change as we delete rows. We could use # QPersistentModelIndex, but easier just to remove them lowest # row first for row in sorted(rows, reverse=True): self.removeRow(row) self._repaint(save_playlist=False) def get_selected_title(self): "Return title of selected row or None" if self.selectionModel().hasSelection(): row = self.currentRow() return self.item(row, self.COL_TITLE).text() else: return None def play_started(self): """ Update current track to be what was next, and determine next track. Return next track_id. """ self.current_track_start_time = datetime.now() current_row = self._meta_get_next() self._meta_set_current(current_row) self.played_tracks.append(current_row) # Scroll to put current track in centre scroll_to = self.item(current_row, self.COL_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtCenter) next_track_id = self._mark_next_track() self._repaint(save_playlist=False) return next_track_id def play_stopped(self): self._meta_clear_current() self.current_track_start_time = None self._repaint(save_playlist=False) def populate(self, session): # add tracks and notes in row order. # We don't mandate that an item will be # on its specified row, only that it will be above # larger-numbered row items, and below lower-numbered ones. # First, save our id for the future self.id = self.db.id data = [] for t in self.db.tracks: data.append(([t.row], t.tracks)) for n in self.db.notes: data.append(([n.row], n)) # Clear playlist self.setRowCount(0) # Now add data in row order for item in sorted(data, key=lambda x: x[0]): self.add_to_playlist(session, item[1], repaint=False) # Scroll to top scroll_to = self.item(0, self.COL_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) self._save_playlist(session) self._repaint() def repaint(self): # Called when we change tabs self._repaint(save_playlist=False) def select_next_track(self): """ Select next or first track. Don't select notes. Wrap at last row. """ selected_rows = [row for row in set([a.row() for a in self.selectedItems()])] # we will only handle zero or one selected rows if len(selected_rows) > 1: return # select first row if none selected if len(selected_rows) == 0: row = 0 else: row = selected_rows[0] + 1 if row >= self.rowCount(): row = 0 # Don't select notes wrapped = False while row in self._meta_get_notes(): row += 1 if row >= self.rowCount(): if wrapped: # we're already wrapped once, so there are no # non-notes return row = 0 wrapped = True self.selectRow(row) def select_previous_track(self): """ Select previous or last track. Don't select notes. Wrap at first row. """ selected_rows = [row for row in set([a.row() for a in self.selectedItems()])] # we will only handle zero or one selected rows if len(selected_rows) > 1: return # select last row if none selected last_row = self.rowCount() - 1 if len(selected_rows) == 0: row = last_row else: row = selected_rows[0] - 1 if row < 0: row = last_row # Don't select notes wrapped = False while row in self._meta_get_notes(): row -= 1 if row < 0: if wrapped: # we're already wrapped once, so there are no # non-notes return row = last_row wrapped = True self.selectRow(row) def set_selected_as_next(self): """ Sets the selected track as the next track. """ if not self.selectionModel().hasSelection(): return return self._set_next(self.currentRow()) # ########## Internally called functions ########## def _calculate_next_start_time(self, session, row, start): "Return this row's end time given its start time" if start is None: return None if row is None: DEBUG("_calculate_next_start_time() called with row=None") return None duration = Tracks.get_duration(session, self._get_row_id(row)) return start + timedelta(milliseconds=duration) def _can_read_track(self, track): "Check track file is readable" return os.access(track.path, os.R_OK) def _context_menu(self, pos): self.menu.exec_(self.mapToGlobal(pos)) def _delete_row(self, row): "Delete row" DEBUG(f"playlist._delete_row({row})") if row == self._meta_get_current(): # TODO DEBUG("playlist._delete_row(): Can't delete playing track") return elif row == self._meta_get_next(): # TODO DEBUG("playlist._delete_row(): Can't delete next track") return with Session() as session: title = self.item(row, self.COL_TITLE).text() msg = QMessageBox(self) msg.setIcon(QMessageBox.Warning) msg.setText(f"Delete '{title}'?") msg.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) msg.setDefaultButton(QMessageBox.Cancel) msg.setWindowTitle("Delete row") if msg.exec() == QMessageBox.Yes: id = self._get_row_id(row) if row in self._meta_get_notes(): Notes.delete_note(session, id) else: PlaylistTracks.remove_track(session, self.db.id, row) self.removeRow(row) self._save_playlist(session) self._repaint() def _drop_on(self, event): index = self.indexAt(event.pos()) if not index.isValid(): return self.rowCount() return (index.row() + 1 if self._is_below(event.pos(), index) else index.row()) def _get_row_id(self, row): "Return item id as integer from passed row" if row is None: return if self.item(row, self.COL_INDEX): try: return int(self.item(row, self.COL_INDEX).text()) except TypeError: ERROR( f"_get_row_id({row}): error retrieving row id " f"({self.item(row, self.COL_INDEX).text()})" ) else: ERROR(f"(_get_row_id({row}): no COL_INDEX data in row") return None def _get_row_time(self, row): try: if self.item(row, self.COL_START_TIME): return datetime.strptime(self.item( row, self.COL_START_TIME).text(), "%H:%M:%S" ) else: return None except ValueError: return None def _is_below(self, pos, index): rect = self.visualRect(index) margin = 2 if pos.y() - rect.top() < margin: return False elif rect.bottom() - pos.y() < margin: return True return ( rect.contains(pos, True) and not (int(self.model().flags(index)) & Qt.ItemIsDropEnabled) and pos.y() >= rect.center().y() # noqa W503 ) def _mark_next_track(self): """ Find next track to play. If not found, return None. If found, mark row with metadata and return track_id. """ found_next_track = False current_row = self._meta_get_current() if current_row is not None: start = current_row + 1 else: start = 0 notes_rows = self._meta_get_notes() for row in range(start, self.rowCount()): if row in notes_rows: continue self._meta_set_next(row) found_next_track = True break if not found_next_track: return None track_id = self._get_row_id(row) return track_id def _meta_clear(self, row): "Clear metadata for row" self._meta_set(row, None) def _meta_clear_current(self): """ Clear current row if there is one. There may not be if we've changed playlists """ current_row = self._meta_get_current() if current_row is not None: self._meta_clear(current_row) def _meta_clear_next(self): """ Clear next row if there is one. There may not be if we've changed playlists """ next_row = self._meta_get_next() if next_row is not None: self._meta_clear(next_row) def _meta_find(self, metadata, one=True): """ Search rows for metadata. If one is True, check that only one row matches and return the row number. If one is False, return a list of matching row numbers. """ matches = [] for row in range(self.rowCount()): if self._meta_get(row) == metadata: matches.append(row) if not one: return matches if len(matches) == 0: return None elif len(matches) == 1: return matches[0] else: ERROR( f"Multiple matches for metadata '{metadata}' found " f"in rows: {', '.join([str(x) for x in matches])}" ) raise AttributeError(f"Multiple '{metadata}' metadata {matches}") def _meta_get(self, row): "Return row metadata" return self.item(row, self.COL_INDEX).data(Qt.UserRole) def _meta_get_current(self): "Return row marked as current, or None" return self._meta_find("current") def _meta_get_next(self): "Return row marked as next, or None" return self._meta_find("next") def _meta_get_notes(self): "Return rows marked as notes, or None" return self._meta_find("note", one=False) def _meta_set_current(self, row): "Mark row as current track" old_current = self._meta_get_current() if old_current is not None: self._meta_clear(old_current) self._meta_set(row, "current") def _meta_set_next(self, row): "Mark row as next track" old_next = self._meta_get_next() if old_next is not None: self._meta_clear(old_next) self._meta_set(row, "next") def _meta_set_note(self, row): "Mark row as note" self._meta_set(row, "note") def _meta_set(self, row, metadata): "Set row metadata" if self.item(row, self.COL_TITLE): title = self.item(row, self.COL_TITLE).text() else: title = "" DEBUG( f"playlist[{self.db.id}:{self.db.name}]._meta_set(row={row}, " f"title={title}, metadata={metadata})" ) if row is None: raise ValueError("_meta_set() with row=None") self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata) def _set_next(self, row): """ If passed row is track row, set that track as the next track to be played and return track_id. Otherwise return None. """ DEBUG(f"_set_next({row})") if row in self._meta_get_notes(): return None track_id = self._get_row_id(row) if track_id: self._meta_set_next(self.currentRow()) self._repaint(save_playlist=False) self.master_process.set_next_track(track_id) def _repaint(self, clear_selection=True): "Set row colours, fonts, etc" DEBUG( f"playlist[{self.db.id}:{self.db.name}]." f"_repaint(clear_selection={clear_selection}" ) with Session() as session: if clear_selection: self.clearSelection() current = self._meta_get_current() next = self._meta_get_next() notes = self._meta_get_notes() # Set colours and start times next_start_time = None # Cycle through all rows for row in range(self.rowCount()): # We can't calculate start times until next_start_time is # set. That can be set by either a note with a time, or the # current track. if row in notes: row_time = self._get_row_time(row) if row_time: next_start_time = row_time # Set colour self._set_row_colour( row, QColor(Config.COLOUR_NOTES_PLAYLIST) ) self._set_row_bold(row) elif row == current: # Set start time self._set_row_time(row, self.current_track_start_time) # Calculate next_start_time next_start_time = self._calculate_next_start_time( session, row, self.current_track_start_time) # Set colour self._set_row_colour(row, QColor( Config.COLOUR_CURRENT_PLAYLIST)) # Make bold self._set_row_bold(row) elif row == next: # if there's a track playing, set start time from that if self.current_track_start_time: start_time = self._calculate_next_start_time( session, current, self.current_track_start_time) else: # No current track to base from, but don't change # time if it's already set start_time = self._get_row_time(row) if not start_time: start_time = next_start_time # Now set it self._set_row_time(row, start_time) next_start_time = self._calculate_next_start_time( session, row, start_time) # Set colour self._set_row_colour( row, QColor(Config.COLOUR_NEXT_PLAYLIST)) # Make bold self._set_row_bold(row) else: # Stripe remaining rows if row % 2: colour = QColor(Config.COLOUR_ODD_PLAYLIST) else: colour = QColor(Config.COLOUR_EVEN_PLAYLIST) self._set_row_colour(row, colour) if self._get_row_id(row) in self.played_tracks: self._set_row_not_bold(row) else: # Set time only if we haven't played it yet if next_start_time: self._set_row_time(row, next_start_time) next_start_time = self._calculate_next_start_time( session, row, next_start_time) # Don't dim unplayed tracks self._set_row_bold(row) def _save_playlist(self, session): """ Save playlist to database. For notes: check the database entry is correct and update it if necessary. Playlists:Note is one:many, so there is only one notes appearance in all playlists. For tracks: erase the playlist tracks and recreate. This is much simpler than trying to correct any Playlists:Tracks many:many errors. """ # We need to add ourself to the session us_in_db = session.query(Playlists).filter( Playlists.id == self.id).one() # Notes first # Create dictionaries indexed by note_id playlist_notes = {} database_notes = {} notes_rows = self._meta_get_notes() # Playlist for row in notes_rows: note_id = self._get_row_id(row) if not note_id: DEBUG(f"(_save_playlist(): no COL_INDEX data in row {row}") continue playlist_notes[note_id] = row # Database for note in us_in_db.notes: database_notes[note.id] = note.row # Notes to add to database # This should never be needed as notes are added to a specific # playlist upon creation for note_id in set(playlist_notes.keys()) - set(database_notes.keys()): ERROR( f"_save_playlist(): Note.id={note_id} " f"missing from playlist {us_in_db} in database" ) # Notes to remove from database for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): DEBUG( f"_save_playlist(): Delete note note_id={note_id} " f"from playlist {us_in_db} in database" ) Notes.delete_note(session, note_id) # Note rows to update in playlist database for note_id in set(playlist_notes.keys()) & set(database_notes.keys()): if playlist_notes[note_id] != database_notes[note_id]: DEBUG( f"_save_playlist(): Update database note.id {note_id} " f"from row={database_notes[note_id]} to " f"row={playlist_notes[note_id]}" ) Notes.update_note(session, note_id, playlist_notes[note_id]) # Tracks # Remove all tracks for us in datbase PlaylistTracks.remove_all_tracks(session, self.db.id) # Iterate on-screen playlist and add tracks back in for row in range(self.rowCount()): if row in notes_rows: continue PlaylistTracks.add_track( session, self.db.id, self._get_row_id(row), row) def _set_column_widths(self): # Column widths from settings with Session() as session: for column in range(self.columnCount()): # Only show column 0 in test mode if (column == 0 and not Config.TESTMODE): self.setColumnWidth(0, 0) else: name = f"playlist_col_{str(column)}_width" record = Settings.get_int(session, name) if record.f_int is not None: self.setColumnWidth(column, record.f_int) def _set_row_bold(self, row, bold=True): boldfont = QFont() boldfont.setBold(bold) for j in range(self.columnCount()): if self.item(row, j): self.item(row, j).setFont(boldfont) def _set_row_colour(self, row, colour): for j in range(self.columnCount()): if self.item(row, j): self.item(row, j).setBackground(colour) def _set_row_not_bold(self, row): self._set_row_bold(row, False) def _set_row_time(self, row, time): try: time_str = time.strftime("%H:%M:%S") except AttributeError: time_str = "" item = QTableWidgetItem(time_str) self.setItem(row, self.COL_START_TIME, item)