from PyQt5 import QtCore from PyQt5.QtCore import Qt from PyQt5.Qt import QFont from PyQt5.QtGui import QColor, QDropEvent from PyQt5.QtWidgets import ( QAbstractItemView, QApplication, QDialog, QHBoxLayout, QListWidgetItem, QMenu, QMessageBox, QTableWidget, QTableWidgetItem, QWidget, ) import helpers import music import os from config import Config from datetime import datetime, timedelta from log import DEBUG, ERROR from model import Notes, Playdates, Playlists, PlaylistTracks, Settings, Tracks from ui.dlg_search_database_ui import Ui_Dialog from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist 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.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.music = music.Music() self.current_track = None self.next_track = None self.playlist_name = None self.playlist_id = 0 self.previous_track = None self.previous_track_position = 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" record = Settings.get_int(name) if record.f_int != self.columnWidth(column): record.update({'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}" ) 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, text): """ 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}") note = Notes.add_note(self.playlist_id, row, text) self.add_to_playlist(note, row=row) def add_to_playlist(self, data, repaint=True, row=None): """ Add data to playlist. Data may be either a Tracks object or a Notes object. """ if not row: if self.selectionModel().hasSelection(): row = self.currentRow() else: row = self.rowCount() DEBUG(f"add_to_playlist(data={data}): row={row}") self.insertRow(row) if isinstance(data, Tracks): DEBUG(f"add_to_playlist: track.id={data.id}") track = data 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) else: # This is a note DEBUG(f"add_to_playlist: note.id={data.id}") note = data # 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" ) item = QTableWidgetItem(str(note.id)) self.setItem(row, self.COL_INDEX, item) titleitem = QTableWidgetItem(data.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, otherwise 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._repaint(clear_selection=False) def create_playlist(self, name): "Create new playlist" new_id = Playlists.new(name) self.load_playlist(new_id) def fade(self): self.previous_track = self.current_track self.previous_track_position = self.music.fade() def get_current_artist(self): try: return self.current_track.artist except AttributeError: return "" def get_current_duration(self): try: return self.current_track.duration except AttributeError: return 0 def get_current_fade_at(self): try: return self.current_track.fade_at except AttributeError: return 0 def get_current_silence_at(self): try: return self.current_track.silence_at except AttributeError: return 0 def get_current_title(self): try: return self.current_track.title except AttributeError: return "" def get_next_artist(self): try: return self.next_track.artist except AttributeError: return "" def get_next_title(self): try: return self.next_track.title except AttributeError: return "" def get_previous_artist(self): try: return self.previous_track.artist except AttributeError: return "" def get_previous_title(self): try: return self.previous_track.title except AttributeError: return "" def load_playlist(self, plid): """ Load tracks and notes from playlist id. Set first track as next track to play. """ DEBUG(f"load_playlist(plid={plid})") p = Playlists.get_playlist_by_id(plid) self.playlist_id = plid self.playlist_name = p.name self.parent().parent().update_statusbar() # We need to retrieve playlist tracks and playlist notes, then # add them 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. data = [] for t in p.tracks: data.append(([t.row], t.tracks)) for n in p.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(item[1], repaint=False) # If this is not the first playlist loaded, we may already have # a next track set in which case don't change it. Otherwise, set # the first non-notes row as next track to play. if not self.next_track: notes_rows = self._meta_get_notes() for row in range(self.rowCount()): if row in notes_rows: continue self._cue_next_track(row) break # Scroll to top scroll_to = self.item(0, self.COL_INDEX) self.scrollToItem(scroll_to, QAbstractItemView.PositionAtTop) def music_ended(self): "Update display" self.previous_track = self.current_track self.previous_track_position = 0 self._meta_clear_current() self._repaint() def play_next(self): """ Play next track. If there is no next track set, return. If there's currently a track playing, fade it. Move next track to current track. Play (new) current. Update playlist "current track" metadata Cue up next track in playlist if there is one. Tell database to record it as played Remember it was played for this session Update metadata and headers, and repaint """ # If there is no next track set, return. if not self.next_track: return DEBUG( "playlist.play_next(), " f"next_track={self.next_track.title if self.next_track else None} " "current_track=" f"{self.current_track.title if self.current_track else None}" ) # If there's currently a track playing, fade it. if self.music.playing(): self.previous_track_position = self.music.fade() self.previous_track = self.current_track # Shuffle tracks along self.current_track = self.next_track self.next_track = None # Play (new) current. self.music.play(self.current_track.path) self.current_track.start_time = datetime.now() # Update metadata self._meta_set_current(self._meta_get_next()) # Set up metadata for next track in playlist if there is one. 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 if self.item(row, self.COL_INDEX): self._cue_next_track(row) break # Tell database to record it as played self.current_track.update_lastplayed() Playdates.add_playdate(self.current_track) # Remember it was played for this session self.played_tracks.append(self.current_track.id) # Update display self._repaint() def search_database(self): dlg = DbDialog(self) dlg.exec() def select_playlist(self): dlg = SelectPlaylistDialog(self) dlg.exec() def set_column_widths(self): # Column widths from settings 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(name) if record.f_int is not None: print("setting column width") self.setColumnWidth(column, record.f_int) def set_selected_as_next(self): """ Sets the selected track as the next track. """ if not self.selectionModel().hasSelection(): return self._set_next(self.currentRow()) def stop(self): "Stop playing immediately" self.previous_track = self.current_track self.previous_track_position = self.music.stop() # ########## Internally called functions ########## def _calculate_next_start_time(self, 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( int(self.item(row, self.COL_INDEX).text())) 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 _cue_next_track(self, next_row): """ Set the passed row as the next track to play """ if next_row is not None: self._meta_set_next(next_row) track_id = int(self.item(next_row, self.COL_INDEX).text()) if not self.next_track or self.next_track.id != track_id: self.next_track = Tracks.get_track(track_id) # Check we can read it if not self._can_read_track(self.next_track): self.parent().parent().show_warning( "Can't read next track", self.next_track.path) else: self.next_track = None # Update display self._repaint() def _delete_row(self, row): "Delete row" if row == self._meta_get_current(): # TODO DEBUG("playlist._delete_row(): Can't delete playing track") elif row == self._meta_get_next(): # TODO DEBUG("playlist._delete_row(): Can't delete next track") else: 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: DEBUG(f"playlist._delete_row(): Delete row {row}") id = int(self.item(row, self.COL_INDEX).text()) if row in self._meta_get_notes(): Notes.delete_note(id) else: PlaylistTracks.remove_track(self.playlist_id, id) self.removeRow(row) 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_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 # noinspection PyTypeChecker return ( rect.contains(pos, True) and not (int(self.model().flags(index)) & Qt.ItemIsDropEnabled) and pos.y() >= rect.center().y() # noqa W503 ) 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: self._meta_clear(current_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"_meta_set(row={row}, title={title}, metadata={metadata})") if row is None: raise ValueError(f"_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 True. Otherwise return False. """ if row in self._meta_get_notes(): return False if self.item(row, self.COL_INDEX): self._cue_next_track(row) return True return False def _repaint(self, clear_selection=True): "Set row colours, fonts, etc, and save playlist" DEBUG(f"_repaint(clear_selection={clear_selection})") self._save_playlist() if clear_selection: self.clearSelection() current = self._meta_get_current() next = self._meta_get_next() or 0 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( 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 current row, set start time from that if self.current_track: start_time = self._calculate_next_start_time( 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( 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 (int(self.item(row, self.COL_INDEX).text()) 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( row, next_start_time) # Don't dim unplayed tracks self._set_row_bold(row) # Headers might need updating self.parent().parent().update_headers() def _save_playlist(self): """ Save playlist to database. Add missing notes/tracks; remove any that are in database but not playlist. Correct row number in database if necessary. """ note_rows = self._meta_get_notes() playlist = Playlists.get_playlist_by_id(self.playlist_id) # Create dictionaries indexed by track/note id playlist_notes = {} playlist_tracks = {} database_notes = {} database_tracks = {} # Playlist for row in range(self.rowCount()): # Get id of item if self.item(row, self.COL_INDEX): id = int(self.item(row, self.COL_INDEX).text()) else: DEBUG(f"(_save_playlist(): no COL_INDEX data in row {row}") continue if row in note_rows: playlist_notes[id] = row else: playlist_tracks[id] = row # Database for note in playlist.notes: database_notes[note.id] = note.row for track in playlist.tracks: database_tracks[track.track_id] = track.row # Notes to remove from playlist in database for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): DEBUG( f"_save_playlist(): Delete note.id={id} " f"from playlist {playlist} in database" ) Notes.delete_note(note_id) # Tracks to remove from playlist in database for track_id in ( set(database_tracks.keys()) - set(playlist_tracks.keys()) ): DEBUG( f"_save_playlist(): Delete track.id={track_id} " f"from playlist {playlist} in database" ) PlaylistTracks.remove_track(playlist.id, track_id) # Notes to add to playlist 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 {playlist} in database" ) # Notes to remove from playlist database for note_id in set(database_notes.keys()) - set(playlist_notes.keys()): DEBUG( f"_save_playlist(): Remove note.id={note_id} " f"from playlist {playlist} in database" ) Notes.delete_note(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(): Set database note.id {note_id} " f"row={playlist_notes[note_id]} " f"in playlist {playlist} in database" ) Notes.update_note(note_id, playlist_notes[note_id]) # Track rows to update in playlist database for track_id in ( set(playlist_tracks.keys()) & set(database_tracks.keys()) ): if playlist_tracks[track_id] != database_tracks[track_id]: DEBUG( f"_save_playlist(): Set database track.id {track_id} " f"row={playlist_tracks[track_id]} " f"in playlist {playlist} in database" ) PlaylistTracks.update_track_row( playlist_id=self.playlist_id, track_id=track_id, old_row=database_tracks[track_id], new_row=playlist_tracks[track_id] ) 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) class DbDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.ui = Ui_Dialog() self.ui.setupUi(self) self.ui.searchString.textEdited.connect(self.chars_typed) self.ui.matchList.itemDoubleClicked.connect(self.double_click) 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) record = Settings.get_int("dbdialog_width") width = record.f_int or 800 record = Settings.get_int("dbdialog_height") height = record.f_int or 600 self.resize(width, height) def __del__(self): record = Settings.get_int("dbdialog_height") if record.f_int != self.height(): record.update({'f_int': self.height()}) record = Settings.get_int("dbdialog_width") if record.f_int != self.width(): record.update({'f_int': self.width()}) def add_selected(self): if not self.ui.matchList.selectedItems(): return item = self.ui.matchList.currentItem() track_id = item.data(Qt.UserRole) self.add_track(track_id) def add_selected_and_close(self): self.add_selected() self.close() def chars_typed(self, s): if len(s) >= 3: matches = Tracks.search_titles(s) self.ui.matchList.clear() if matches: for track in matches: t = QListWidgetItem() t.setText( f"{track.title} - {track.artist} " f"[{helpers.ms_to_mmss(track.duration)}]" ) t.setData(Qt.UserRole, track.id) self.ui.matchList.addItem(t) def double_click(self, entry): track_id = entry.data(Qt.UserRole) self.add_track(track_id) # Select search text to make it easier for next search self.select_searchtext() def add_track(self, track_id): track = Tracks.track_from_id(track_id) self.parent().add_to_playlist(track) # Select search text to make it easier for next search self.select_searchtext() def select_searchtext(self): self.ui.searchString.selectAll() self.ui.searchString.setFocus() class SelectPlaylistDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.ui = Ui_dlgSelectPlaylist() self.ui.setupUi(self) self.ui.lstPlaylists.itemDoubleClicked.connect(self.listdclick) for (plid, plname) in [ (a.id, a.name) for a in Playlists.get_all_playlists() ]: p = QListWidgetItem() p.setText(plname) p.setData(Qt.UserRole, plid) self.ui.lstPlaylists.addItem(p) def listdclick(self, entry): plid = entry.data(Qt.UserRole) self.parent().load_playlist(plid) class Window(QWidget): def __init__(self): super(Window, self).__init__() layout = QHBoxLayout() self.setLayout(layout) self.table_widget = Playlist() layout.addWidget(self.table_widget) # setup table widget self.table_widget.setColumnCount(2) self.table_widget.setHorizontalHeaderLabels(['Type', 'Name']) items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle'), ('Silver', 'Chevy'), ('Black', 'BMW')] self.table_widget.setRowCount(len(items)) for i, (color, model) in enumerate(items): self.table_widget.setItem(i, 0, QTableWidgetItem(color)) self.table_widget.setItem(i, 1, QTableWidgetItem(model)) self.resize(400, 400) self.show() if __name__ == '__main__': import sys app = QApplication(sys.argv) window = Window() sys.exit(app.exec_())