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 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 = 1 COL_TITLE = 2 COL_ARTIST = 3 COL_DURATION = 4 COL_ENDTIME = 5 COL_PATH = 6 NOTE_COL_SPAN = 6 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.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 = [] # Column widths from settings for column in range(self.columnCount()): name = f"playlist_col_{str(column)}_width" record = Settings.get_int(name) if record.f_int is not None: self.setColumnWidth(column, record.f_int) def __del__(self): "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}) def eventFilter(self, source, event): 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"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) def context_menu(self, pos): self.menu.exec_(self.mapToGlobal(pos)) def delete_row(self, row): "Delete row" notes_rows = self.meta_get_notes() if row in notes_rows: # TODO DEBUG("Delete notes not yet implemented") elif row == self.meta_get_current(): # TODO DEBUG("Can't delete playing track") elif row == self.meta_get_next(): # TODO DEBUG("Can't delete next track") else: track_title = self.item(row, self.COL_TITLE).text() msg = QMessageBox() msg.setIcon(QMessageBox.Warning) msg.setText(f"Delete '{track_title}'?") msg.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) msg.setDefaultButton(QMessageBox.Cancel) msg.setWindowTitle("Delete row") if msg.exec() == QMessageBox.Yes: DEBUG(f"Delete row {row}") track_id = int(self.item(row, self.COL_INDEX).text()) PlaylistTracks.remove_track(self.playlist_id, track_id) self.removeRow(row) self.repaint() def add_note(self, text): """ Add note to playlist If a row is selected, add note above. Otherwise, add to end of playlist. """ DEBUG(f"add_note({text})") DEBUG(f"self.currentRow()={self.currentRow()}") row = self.currentRow() if row < 0: row = self.rowCount() DEBUG(f"row={row}") note_id = Notes.add_note(self.playlist_id, row, text) self.insertRow(row) item = QTableWidgetItem(str(note_id)) self.setItem(row, self.COL_INDEX, item) item = QTableWidgetItem(text) self.setItem(row, self.COL_NOTE, item) self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) self.meta_set_note(row) self.repaint() def add_to_playlist(self, data, repaint=True): """ Add data to playlist. Data may be either a Tracks object or a Notes object. """ row = self.rowCount() self.insertRow(row) DEBUG(f"Adding to row {row}") if isinstance(data, Tracks): 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) item = QTableWidgetItem(track.title) self.setItem(row, self.COL_TITLE, item) 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) DEBUG(f"add_to_playlist: track.id={track.id}") else: # This is a note item = QTableWidgetItem(str(data.id)) self.setItem(row, self.COL_INDEX, item) item = QTableWidgetItem(data.note) self.setItem(row, self.COL_NOTE, item) self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, self.NOTE_COL_SPAN) self.meta_set_note(row) if repaint: self.repaint() def create_playlist(self, name): "Create new playlist" new_id = Playlists.new(name) self.load_playlist(new_id) 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 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() 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) super().dropEvent(event) DEBUG(f"Moved row(s) {rows} to become row {drop_row}") self.clearSelection() self.repaint() def fade(self): 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_playtime(self): return self.music.get_playtime() 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_next_track_id(self): try: return self.next_track.id except AttributeError: return 0 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 get_row_endtime(self, row, start): "Return this row's end time given its start time" duration = Tracks.get_duration( int(self.item(row, self.COL_INDEX).text())) return start + timedelta(milliseconds=duration) 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 load_playlist(self, plid): """ Load tracks and notes from playlist id. Set first track as next track to play. Return True if successful else False. """ DEBUG(f"load_playlist({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]): DEBUG(f"Adding {item}") self.add_to_playlist(item[1], repaint=False) # Set the first non-notes row as next track to play notes_rows = self.meta_get_notes() for row in range(self.rowCount()): if row in notes_rows: continue self.meta_set_next(row) self.tracks_changed() return True return False def meta_clear(self, row): "Clear metadata for row" self.meta_set(row, None) 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" DEBUG(f"meta_set_current({row})") 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" DEBUG(f"meta_set_next({row})") 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" self.item(row, self.COL_INDEX).setData(Qt.UserRole, metadata) 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 # 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) # Update metadata self.meta_set_current(self.meta_get_next()) # Set track start time current_row = self.meta_get_current() endtime = datetime.now() + timedelta( milliseconds=self.current_track.silence_at) item = QTableWidgetItem(endtime.strftime("%H:%M:%S")) self.setItem(current_row, self.COL_ENDTIME, item) # Set up metadata for next track in playlist if there is one. if current_row is not None: start = current_row + 1 else: start = 0 for row in range(start, self.rowCount()): if self.item(row, self.COL_INDEX): if int(self.item(row, self.COL_INDEX).text()) > 0: self.meta_set_next(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.tracks_changed() def search_database(self): dlg = DbDialog(self) dlg.exec() def select_playlist(self): dlg = SelectPlaylistDialog(self) dlg.exec() def set_selected_as_next(self): """ Sets the selected track as the next track. """ self.set_next(self.currentRow()) 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.meta_set_next(row) self.tracks_changed() return True return False def music_ended(self): "Update display" self.previous_track = self.current_track self.previous_track_position = 0 self.meta_clear(self.meta_get_current()) self.tracks_changed() def repaint(self): "Set row colours, fonts, etc, and save playlist" self.save() self.clearSelection() current = self.meta_get_current() next = self.meta_get_next() notes = self.meta_get_notes() # Set colours and start times running_end_time = None for row in range(self.rowCount()): if row == current: self.set_row_colour( row, QColor(Config.COLOUR_CURRENT_PLAYLIST) ) try: running_end_time = datetime.strptime(self.item( row, self.COL_ENDTIME).text(), "%H:%M:%S") except AttributeError: pass self.set_row_bold(row) elif row == next: self.set_row_colour( row, QColor(Config.COLOUR_NEXT_PLAYLIST) ) if running_end_time: running_end_time = self.get_row_endtime( row, running_end_time) item = QTableWidgetItem( running_end_time.strftime("%H:%M:%S")) self.setItem(row, self.COL_ENDTIME, item) self.set_row_bold(row) elif row in notes: self.set_row_colour( row, QColor(Config.COLOUR_NOTES_PLAYLIST) ) self.set_row_bold(row) else: # Stripe rows if row % 2: colour = QColor(Config.COLOUR_ODD_PLAYLIST) else: colour = QColor(Config.COLOUR_EVEN_PLAYLIST) self.set_row_colour(row, colour) # Add running end time if self.item(row, self.COL_INDEX): if int(self.item(row, self.COL_INDEX).text()) > 0: if running_end_time: running_end_time = self.get_row_endtime( row, running_end_time) item = QTableWidgetItem( running_end_time.strftime("%H:%M:%S")) self.setItem(row, self.COL_ENDTIME, item) # Dim played tracks track_id = int(self.item(row, self.COL_INDEX).text()) if track_id in self.played_tracks: self.set_row_not_bold(row) else: self.set_row_bold(row) # Headers might need updating self.parent().parent().update_headers() def save(self): """ Save playlist to database. Notes are also saved. """ # Create list of current tracks (row, track_id) for this playlst and # compare with actual playlist. Fix as required. # Repeat for notes (row, id, text) tracks = {} notes = {} note_rows = self.meta_get_notes() for row in range(self.rowCount()): if self.item(row, self.COL_INDEX): id = int(self.item(row, self.COL_INDEX).text()) else: DEBUG(f"(no COL_INDEX data in row {row}") continue if row in note_rows: notes[id] = (row, self.item(row, self.COL_NOTE).text()) else: tracks[id] = row # Get tracks and notes from database db_tracks = {} db_notes = {} p = Playlists.get_playlist_by_id(self.playlist_id) for track in p.tracks: db_tracks[track.track_id] = track.row for note in p.notes: db_notes[note.id] = (note.row, note.note) # Note ids to remove from db for id in set(db_notes.keys()) - set(notes.keys()): DEBUG(f"Delete note.id={id} from database") # Note ids to add to db for id in set(notes.keys()) - set(db_notes.keys()): DEBUG(f"Add note.id={id} to database") # Notes to update in db for id in set(notes.keys()) & set(db_notes.keys()): if notes[id] != db_notes[id]: DEBUG(f"Update db note.id={id} in database") Notes.update_note(id, row=notes[id][0], text=notes[id][1]) # Track ids to remove from db for id in set(db_tracks.keys()) - set(tracks.keys()): DEBUG(f"Delete track.id={id} from database") # Track ids to add to db for id in set(tracks.keys()) - set(db_tracks.keys()): DEBUG(f"Add track.id={id} to database") # Tracks to update in db for id in set(tracks.keys()) & set(db_tracks.keys()): if tracks[id] != db_tracks[id]: DEBUG(f"Update db track.id={id} in database") PlaylistTracks.update_track_row( self.playlist_id, track_id=id, old_row=db_tracks[id], new_row=tracks[id] ) def set_row_bold(self, row): bold = QFont() bold.setBold(True) for j in range(self.columnCount()): if self.item(row, j): self.item(row, j).setFont(bold) 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): normal = QFont() normal.setBold(False) for j in range(self.columnCount()): if self.item(row, j): self.item(row, j).setFont(normal) def tracks_changed(self): """ One or more of current or next track has changed. The row metadata is definitive because the same track may appear more than once in the playlist, but only one track may be marked as current or next. Update self.current_track and self.next_track. """ # Update instance variables current_row = self.meta_get_current() if current_row is not None: track_id = int(self.item(current_row, self.COL_INDEX).text()) if not self.current_track or self.current_track.id != track_id: self.current_track = Tracks.get_track(track_id) else: self.current_track = None next_row = self.meta_get_next() if next_row is not None: 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) else: self.next_track = None try: DEBUG(f"tracks_changed() previous={self.previous_track.id}") except AttributeError: DEBUG("tracks_changed() previous=None") try: DEBUG(f"tracks_changed() current={self.current_track.id}") except AttributeError: DEBUG("tracks_changed() current=None") try: DEBUG(f"tracks_changed() next={self.next_track.id}") except AttributeError: DEBUG("tracks_changed() next=None") # Update display self.repaint() 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.listdclick) 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 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 listdclick(self, entry): track_id = entry.data(Qt.UserRole) track = Tracks.track_from_id(track_id) # Store in current playlist in database db_playlist = Playlists.get_playlist_by_id(self.parent().playlist_id) # TODO: hack position to be at end of list db_playlist.add_track(track, 99999) # Add to on-screen playlist self.parent().add_to_playlist(track) 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_())