musicmuster/app/playlists.py
2021-04-04 19:49:33 +01:00

544 lines
16 KiB
Python

from PyQt5.QtCore import Qt
from PyQt5.Qt import QFont
from PyQt5.QtGui import QColor, QDropEvent
from PyQt5.QtWidgets import (
QTableWidget,
QAbstractItemView,
QTableWidgetItem,
QWidget,
QHBoxLayout,
QApplication
)
import helpers
from config import Config
from log import DEBUG, ERROR
from model import Playdates, Playlists, Settings, Tracks
class Playlist(QTableWidget):
# Column names
COL_INDEX = 0
COL_MSS = 1
COL_TITLE = 2
COL_ARTIST = 3
COL_DURATION = 4
COL_ENDTIME = 5
COL_PATH = 6
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)
self.current_track = None
self.next_track = None
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)
# How to put in non-track entries for later
# row = self.rowCount()
# self.insertRow(row)
# item = QTableWidgetItem("to be spanned")
# self.setItem(row, 1, item)
# self.setSpan(row, 1, 1, 3)
# colour = QColor(125, 75, 25)
# self.set_row_colour(row, colour)
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 add_to_playlist(self, track):
"""
Add track to playlist
track is an instance of Track
"""
DEBUG(f"add_to_playlist: track.id={track.id}")
row = self.rowCount()
self.insertRow(row)
DEBUG(f"Adding to row {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)
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)
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 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, name):
"""
Load tracks from named playlist.
Set first track as next track to play.
Return True if successful else False.
"""
DEBUG(f"load_playlist({name})")
for track in Playlists.get_playlist_by_name(name).get_tracks():
self.add_to_playlist(track)
# Set the first playable track as next to play
for row in range(self.rowCount()):
if self.item(row, self.COL_INDEX):
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 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
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 set_selected_as_next(self):
"""
Sets the selected track as the next track.
"""
self.set_row_as_next_track(self.currentRow())
def set_row_as_next_track(self, row):
"""
If passed row is track row, indicated by having a track_id in the
COL_INDEX column, set that track as the next track to be played
and return True. Otherwise return False.
"""
if self.item(row, self.COL_INDEX):
self.meta_set_current(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"
self.clearSelection()
current = self.meta_get_current()
next = self.meta_get_next()
notes = self.meta_get_notes()
for row in range(self.rowCount()):
if row == current:
self.set_row_colour(
row, QColor(Config.COLOUR_CURRENT_PLAYLIST)
)
self.set_row_bold(row)
elif row == next:
self.set_row_colour(
row, QColor(Config.COLOUR_NEXT_PLAYLIST)
)
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:
if row % 2:
colour = QColor(Config.COLOUR_ODD_PLAYLIST)
else:
colour = QColor(Config.COLOUR_EVEN_PLAYLIST)
self.set_row_colour(row, colour)
if self.item(row, self.COL_INDEX):
if int(self.item(row, self.COL_INDEX).text()) > 0:
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)
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 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_())