583 lines
18 KiB
Python
583 lines
18 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 datetime import datetime, timedelta
|
|
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 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, 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 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 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()
|
|
|
|
# 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)
|
|
)
|
|
running_end_time = datetime.strptime(self.item(
|
|
row, self.COL_ENDTIME).text(), "%H:%M:%S")
|
|
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)
|
|
|
|
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_())
|