Refactor into modules
This commit is contained in:
parent
cf52eb3c9c
commit
bcfd076a93
19
app/helpers.py
Normal file
19
app/helpers.py
Normal file
@ -0,0 +1,19 @@
|
||||
from log import DEBUG
|
||||
|
||||
|
||||
def ms_to_mmss(ms, decimals=0, negative=False):
|
||||
if not ms:
|
||||
return "-"
|
||||
sign = ""
|
||||
if ms < 0:
|
||||
if negative:
|
||||
sign = "-"
|
||||
else:
|
||||
ms = 0
|
||||
|
||||
minutes, remainder = divmod(ms, 60 * 1000)
|
||||
seconds = remainder / 1000
|
||||
if int(seconds) == 60:
|
||||
DEBUG(f"ms_to_mmss({ms}) gave 60 seconds")
|
||||
|
||||
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
|
||||
10
app/model.py
10
app/model.py
@ -1,6 +1,5 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import os
|
||||
import sqlalchemy
|
||||
|
||||
from datetime import datetime
|
||||
@ -102,7 +101,8 @@ class Playlists(Base):
|
||||
tr = session.query(Tracks).filter(Tracks.id == 676).one()
|
||||
|
||||
tr
|
||||
<Track(id=676, title=Seven Nation Army, artist=White Stripes, path=/home/kae/music/White Stripes/Elephant/01. Seven Nation Army.flac>
|
||||
<Track(id=676, title=Seven Nation Army, artist=White Stripes,
|
||||
path=/home/kae/music/White Stripes/Elephant/01. Seven Nation Army.flac>
|
||||
|
||||
glue.track_id = tr.id
|
||||
|
||||
@ -132,8 +132,10 @@ class Playlists(Base):
|
||||
# Currently we only support one playlist, so make that obvious from
|
||||
# function name
|
||||
@classmethod
|
||||
def get_only_playlist(cls):
|
||||
return session.query(Playlists).filter(Playlists.id == 1).one()
|
||||
def get_playlist_by_name(cls, name):
|
||||
"Returns a playlist object for named playlist"
|
||||
|
||||
return session.query(Playlists).filter(Playlists.name == name).one()
|
||||
|
||||
def add_track(self, track, position):
|
||||
glue = PlaylistTracks(sort=position)
|
||||
|
||||
94
app/music.py
Normal file
94
app/music.py
Normal file
@ -0,0 +1,94 @@
|
||||
import os
|
||||
import threading
|
||||
import vlc
|
||||
|
||||
from config import Config
|
||||
from time import sleep
|
||||
|
||||
from log import DEBUG
|
||||
|
||||
|
||||
class Music:
|
||||
"""
|
||||
Manage the playing of music tracks
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.fading = False
|
||||
self.player = None
|
||||
self.track_path = None
|
||||
|
||||
def fade(self):
|
||||
"""
|
||||
Fade the currently playing track.
|
||||
|
||||
Return the current track path and position.
|
||||
|
||||
The actual management of fading runs in its own thread so as not
|
||||
to hold up the UI during the fade.
|
||||
"""
|
||||
|
||||
DEBUG("fade()")
|
||||
|
||||
if not self.playing():
|
||||
return (None, None)
|
||||
|
||||
path = self.track_path
|
||||
position = self.player.get_position()
|
||||
|
||||
thread = threading.Thread(target=self._fade)
|
||||
thread.start()
|
||||
|
||||
return (path, position)
|
||||
|
||||
def _fade(self):
|
||||
"""
|
||||
Implementation of fading the current track in a separate thread.
|
||||
"""
|
||||
|
||||
DEBUG("_fade()")
|
||||
|
||||
fade_time = Config.FADE_TIME / 1000
|
||||
sleep_time = fade_time / Config.FADE_STEPS
|
||||
step_percent = int((100 / Config.FADE_STEPS) * -1)
|
||||
|
||||
# Take a copy of current player to allow another track to be
|
||||
# started without interfering here
|
||||
p = self.player
|
||||
for i in range(100, 0, step_percent):
|
||||
p.audio_set_volume(i)
|
||||
sleep(sleep_time)
|
||||
|
||||
p.pause()
|
||||
p.release()
|
||||
|
||||
def get_playtime(self):
|
||||
"Return elapsed play time"
|
||||
|
||||
return self.player.get_time()
|
||||
|
||||
def play(self, path):
|
||||
"""
|
||||
Start playing the track at path.
|
||||
|
||||
Raise AttributeError if path not found.
|
||||
"""
|
||||
|
||||
DEBUG(f"play({path})")
|
||||
|
||||
if not os.access(path, os.R_OK):
|
||||
raise AttributeError(f"Cannot access {path}")
|
||||
|
||||
self.track_path = path
|
||||
|
||||
self.player = vlc.MediaPlayer(path)
|
||||
self.player.audio_set_volume(100)
|
||||
self.player.play()
|
||||
|
||||
def playing(self):
|
||||
"Return True if currently playing a track, else False"
|
||||
|
||||
if self.player:
|
||||
return self.player.is_playing()
|
||||
else:
|
||||
return False
|
||||
@ -1,180 +1,25 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import vlc
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from log import DEBUG, ERROR
|
||||
# from log import DEBUG, ERROR
|
||||
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QTableWidgetItem,
|
||||
)
|
||||
|
||||
from ui.main_window_ui import Ui_MainWindow
|
||||
from ui.dlg_search_database_ui import Ui_Dialog
|
||||
import helpers
|
||||
|
||||
from config import Config
|
||||
from model import Playdates, Playlists, Settings, Tracks
|
||||
from time import sleep
|
||||
|
||||
|
||||
class Music:
|
||||
def __init__(self):
|
||||
self.current_track = {
|
||||
"player": None,
|
||||
"meta": None
|
||||
}
|
||||
self.next_track = {
|
||||
"player": None,
|
||||
"meta": None
|
||||
}
|
||||
self.previous_track = {
|
||||
"player": None,
|
||||
"meta": None
|
||||
}
|
||||
self.fading = False
|
||||
|
||||
def get_current_artist(self):
|
||||
try:
|
||||
return self.current_track['meta'].artist
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_current_duration(self):
|
||||
try:
|
||||
return self.current_track['meta'].duration
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def get_current_fade_at(self):
|
||||
try:
|
||||
return self.current_track['meta'].fade_at
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def get_current_playtime(self):
|
||||
try:
|
||||
return self.current_track['player'].get_time()
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def get_current_silence_at(self):
|
||||
try:
|
||||
return self.current_track['meta'].silence_at
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def get_current_title(self):
|
||||
try:
|
||||
return self.current_track['meta'].title
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_current_track_id(self):
|
||||
try:
|
||||
return self.current_track['meta'].id
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def get_previous_artist(self):
|
||||
try:
|
||||
return self.previous_track['meta'].artist
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_previous_title(self):
|
||||
try:
|
||||
return self.previous_track['meta'].title
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_next_artist(self):
|
||||
try:
|
||||
return self.next_track['meta'].artist
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_next_title(self):
|
||||
try:
|
||||
return self.next_track['meta'].title
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def get_next_track_id(self):
|
||||
try:
|
||||
return self.next_track['meta'].id
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
def fade_current(self):
|
||||
if not self.playing():
|
||||
return
|
||||
thread = threading.Thread(target=self._fade_current)
|
||||
thread.start()
|
||||
|
||||
def _fade_current(self):
|
||||
fade_time = Config.FADE_TIME / 1000
|
||||
sleep_time = fade_time / Config.FADE_STEPS
|
||||
step_percent = int((100 / Config.FADE_STEPS) * -1)
|
||||
player = self.current_track['player']
|
||||
position = player.get_position()
|
||||
for i in range(100, 0, step_percent):
|
||||
player.audio_set_volume(i)
|
||||
sleep(sleep_time)
|
||||
# If the user clicks "fade" and then, before the track has
|
||||
# finished fading, click "play next", the "play next" will also
|
||||
# call fade_current. When that second call to fade_current gets
|
||||
# to the player.pause() line below, it will unpause it. So, we
|
||||
# only pause if we're acutally playing.
|
||||
if player.is_playing():
|
||||
player.pause()
|
||||
player.audio_set_volume(100)
|
||||
player.set_position(position)
|
||||
|
||||
def play_next(self):
|
||||
if self.previous_track['player']:
|
||||
self.previous_track['player'].release()
|
||||
if self.current_track['player']:
|
||||
self.fade_current()
|
||||
self.previous_track = self.current_track
|
||||
self.current_track = self.next_track
|
||||
# Next in case player was left in odd (ie, silenced) state
|
||||
self.current_track['player'].audio_set_volume(100)
|
||||
self.current_track['player'].play()
|
||||
Playdates.add_playdate(self.current_track['meta'])
|
||||
|
||||
# Tidy up
|
||||
self.next_track = {
|
||||
"player": None,
|
||||
"meta": None
|
||||
}
|
||||
|
||||
def playing(self):
|
||||
if self.current_track['player']:
|
||||
return self.current_track['player'].is_playing()
|
||||
else:
|
||||
return False
|
||||
|
||||
def resume_previous(self):
|
||||
pass
|
||||
|
||||
def set_next_track(self, id):
|
||||
track = Tracks.get_track(id)
|
||||
if not track:
|
||||
ERROR(f"set_next_track({id}): can't find track")
|
||||
return None
|
||||
|
||||
self.next_track['player'] = vlc.MediaPlayer(track.path)
|
||||
self.next_track['meta'] = track
|
||||
return track.id
|
||||
from model import Playlists, Settings, Tracks
|
||||
from ui.dlg_search_database_ui import Ui_Dialog
|
||||
from ui.main_window_ui import Ui_MainWindow
|
||||
|
||||
|
||||
class Window(QMainWindow, Ui_MainWindow):
|
||||
@ -182,8 +27,7 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
super().__init__(parent)
|
||||
self.setupUi(self)
|
||||
self.timer = QTimer()
|
||||
self.connectSignalsSlots()
|
||||
self.music = Music()
|
||||
self.connect_signals_slots()
|
||||
self.disable_play_next_controls()
|
||||
|
||||
record = Settings.get_int("mainwindow_x")
|
||||
@ -196,39 +40,12 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
height = record.f_int or 981
|
||||
self.setGeometry(x, y, width, height)
|
||||
|
||||
for column in range(self.playlist.columnCount()):
|
||||
name = f"playlist_col_{str(column)}_width"
|
||||
record = Settings.get_int(name)
|
||||
if record.f_int is not None:
|
||||
self.playlist.setColumnWidth(column, record.f_int)
|
||||
|
||||
# Load playlist
|
||||
db_playlist = Playlists.get_only_playlist()
|
||||
for track in db_playlist.get_tracks():
|
||||
self.add_to_playlist(track)
|
||||
# Set the first playable track as next to play
|
||||
for row in range(self.playlist.rowCount()):
|
||||
if self.set_next_track_row(row):
|
||||
break
|
||||
|
||||
pl = self.playlist
|
||||
row = pl.rowCount()
|
||||
pl.insertRow(row)
|
||||
item = QTableWidgetItem("to be spanned")
|
||||
pl.setItem(row, 1, item)
|
||||
pl.setSpan(row, 1, 1, 3)
|
||||
colour = QColor(125, 75, 25)
|
||||
pl.set_row_colour(row, colour)
|
||||
# Hard code to the only playlist we have for now
|
||||
self.playlist.load_playlist("Default")
|
||||
|
||||
self.timer.start(Config.TIMER_MS)
|
||||
|
||||
def __del__(self):
|
||||
for column in range(self.playlist.columnCount()):
|
||||
name = f"playlist_col_{str(column)}_width"
|
||||
record = Settings.get_int(name)
|
||||
if record.f_int != self.playlist.columnWidth(column):
|
||||
record.update({'f_int': self.playlist.columnWidth(column)})
|
||||
|
||||
record = Settings.get_int("mainwindow_height")
|
||||
if record.f_int != self.height():
|
||||
record.update({'f_int': self.height()})
|
||||
@ -245,28 +62,47 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
if record.f_int != self.y():
|
||||
record.update({'f_int': self.y()})
|
||||
|
||||
def connectSignalsSlots(self):
|
||||
def add_file(self):
|
||||
dlg = QFileDialog()
|
||||
dlg.setFileMode(QFileDialog.ExistingFile)
|
||||
dlg.setViewMode(QFileDialog.Detail)
|
||||
dlg.setDirectory(Config.ROOT)
|
||||
dlg.setNameFilter("Music files (*.flac *.mp3)")
|
||||
|
||||
if dlg.exec_():
|
||||
pass
|
||||
# TODO Add to database
|
||||
# for fname in dlg.selectedFiles():
|
||||
# trackwest = Track(fname)
|
||||
# self.add_to_playlist(track)
|
||||
|
||||
def clear_selection(self):
|
||||
self.playlist.clearSelection()
|
||||
|
||||
def connect_signals_slots(self):
|
||||
self.action_Clear_selection.triggered.connect(self.clear_selection)
|
||||
self.actionFade.triggered.connect(self.fade)
|
||||
self.actionPlay_next.triggered.connect(self.play_next)
|
||||
self.actionPlay_selected.triggered.connect(self.play_next)
|
||||
self.actionSearch_database.triggered.connect(self.selectFromDatabase)
|
||||
self.btnSearchDatabase.clicked.connect(self.selectFromDatabase)
|
||||
self.btnSetNextTrack.clicked.connect(self.set_next_track)
|
||||
self.actionSearch_database.triggered.connect(self.search_database)
|
||||
self.btnPrevious.clicked.connect(self.play_previous)
|
||||
self.btnSearchDatabase.clicked.connect(self.search_database)
|
||||
self.btnSetNextTrack.clicked.connect(self.playlist.set_next_track)
|
||||
self.btnSkipNext.clicked.connect(self.play_next)
|
||||
self.btnStop.clicked.connect(self.fade)
|
||||
|
||||
self.timer.timeout.connect(self.tick)
|
||||
|
||||
def selectFromDatabase(self):
|
||||
dlg = DbDialog(self)
|
||||
dlg.exec()
|
||||
def disable_play_next_controls(self):
|
||||
self.actionPlay_selected.setEnabled(False)
|
||||
self.actionPlay_next.setEnabled(False)
|
||||
|
||||
def clear_selection(self):
|
||||
self.playlist.clearSelection()
|
||||
def enable_play_next_controls(self):
|
||||
self.actionPlay_selected.setEnabled(True)
|
||||
self.actionPlay_next.setEnabled(True)
|
||||
|
||||
def fade(self):
|
||||
self.music.fade_current()
|
||||
self.playlist.fade()
|
||||
|
||||
def play_next(self):
|
||||
self.music.play_next()
|
||||
@ -286,141 +122,72 @@ class Window(QMainWindow, Ui_MainWindow):
|
||||
silence_at = self.music.get_current_silence_at()
|
||||
silence_time = now + timedelta(milliseconds=silence_at)
|
||||
self.label_silent_tod.setText(silence_time.strftime("%H:%M:%S"))
|
||||
self.label_fade_length.setText(ms_to_mmss(
|
||||
self.label_fade_length.setText(helpers.ms_to_mmss(
|
||||
silence_at - self.music.get_current_fade_at()))
|
||||
self.set_playlist_colours()
|
||||
|
||||
def set_playlist_colours(self):
|
||||
self.playlist.clearSelection()
|
||||
current_track = self.music.get_current_track_id()
|
||||
next_track = self.music.get_next_track_id()
|
||||
for row in range(self.playlist.rowCount()):
|
||||
try:
|
||||
track_id = int(self.playlist.item(row, 0).text())
|
||||
if track_id == next_track:
|
||||
self.playlist.set_row_colour(
|
||||
row, QColor(Config.COLOUR_NEXT_PLAYLIST))
|
||||
elif track_id == current_track:
|
||||
self.playlist.set_row_colour(
|
||||
row, QColor(Config.COLOUR_CURRENT_PLAYLIST))
|
||||
else:
|
||||
if row % 2:
|
||||
colour = QColor(Config.COLOUR_ODD_PLAYLIST)
|
||||
else:
|
||||
colour = QColor(Config.COLOUR_EVEN_PLAYLIST)
|
||||
self.playlist.set_row_colour(row, colour)
|
||||
except AttributeError:
|
||||
pass
|
||||
def play_previous(self):
|
||||
"Resume playing last track"
|
||||
|
||||
def play_selected(self):
|
||||
if self.playlist.selectionModel().hasSelection():
|
||||
row = self.playlist.currentRow()
|
||||
track_id = int(self.playlist.item(row, 0).text())
|
||||
DEBUG(f"play_selected: track_id={track_id}")
|
||||
# TODO: get_path may raise exception
|
||||
track_path = Tracks.get_path(track_id)
|
||||
if track_path:
|
||||
DEBUG(f"play_selected: track_path={track_path}")
|
||||
player = vlc.MediaPlayer(track_path)
|
||||
player.play()
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def selectFile(self):
|
||||
dlg = QFileDialog()
|
||||
dlg.setFileMode(QFileDialog.ExistingFile)
|
||||
dlg.setViewMode(QFileDialog.Detail)
|
||||
dlg.setDirectory(Config.ROOT)
|
||||
dlg.setNameFilter("Music files (*.flac *.mp3)")
|
||||
|
||||
if dlg.exec_():
|
||||
pass
|
||||
# TODO Add to database
|
||||
# for fname in dlg.selectedFiles():
|
||||
# track = Track(fname)
|
||||
# self.add_to_playlist(track)
|
||||
|
||||
def set_next_track(self):
|
||||
"""
|
||||
Sets the selected track as the next track.
|
||||
"""
|
||||
|
||||
if self.playlist.selectionModel().hasSelection():
|
||||
self.set_next_track_row(self.playlist.currentRow())
|
||||
|
||||
def set_next_track_row(self, row):
|
||||
if self.playlist.item(row, 0):
|
||||
track_id = int(self.playlist.item(row, 0).text())
|
||||
if track_id == self.music.get_current_track_id():
|
||||
# Don't set current track as next track
|
||||
return
|
||||
DEBUG(f"set_next_track: track_id={track_id}")
|
||||
if self.music.set_next_track(track_id) != track_id:
|
||||
ERROR("Can't set next track")
|
||||
self.next_track.setText(
|
||||
f"{self.music.get_next_title()} - "
|
||||
f"{self.music.get_next_artist()}"
|
||||
)
|
||||
self.enable_play_next_controls()
|
||||
self.playlist.clearSelection()
|
||||
self.set_playlist_colours()
|
||||
return True
|
||||
|
||||
return False
|
||||
def search_database(self):
|
||||
dlg = DbDialog(self)
|
||||
dlg.exec()
|
||||
|
||||
def tick(self):
|
||||
pass
|
||||
# self.current_time.setText(now.strftime("%H:%M:%S"))
|
||||
if self.music.playing():
|
||||
playtime = self.music.get_current_playtime()
|
||||
self.label_elapsed_timer.setText(ms_to_mmss(playtime))
|
||||
self.label_fade_timer.setText(
|
||||
ms_to_mmss(self.music.get_current_fade_at() - playtime))
|
||||
self.label_silent_timer.setText(
|
||||
ms_to_mmss(self.music.get_current_silence_at() - playtime))
|
||||
self.label_end_timer.setText(
|
||||
ms_to_mmss(self.music.get_current_duration() - playtime))
|
||||
else:
|
||||
# When music ends, ensure next track is selected
|
||||
if self.playlist.selectionModel().hasSelection():
|
||||
row = self.playlist.currentRow()
|
||||
track_id = int(self.playlist.item(row, 0).text())
|
||||
if track_id == self.music.get_current_track_id():
|
||||
# Current track highlighted: select next
|
||||
try:
|
||||
self.playlist.selectRow(row + 1)
|
||||
except AttributeError:
|
||||
pass
|
||||
# if self.music.playing():
|
||||
# playtime = self.music.get_current_playtime()
|
||||
# self.label_elapsed_timer.setText(helpers.ms_to_mmss(playtime))
|
||||
# self.label_fade_timer.setText(
|
||||
# helpers.ms_to_mmss(self.music.get_current_fade_at() - playtime)
|
||||
# )
|
||||
# self.label_silent_timer.setText(
|
||||
# helpers.ms_to_mmss(
|
||||
# self.music.get_current_silence_at() - playtime))
|
||||
# self.label_end_timer.setText(
|
||||
# helpers.ms_to_mmss(
|
||||
# self.music.get_current_duration() - playtime))
|
||||
# else:
|
||||
# # When music ends, ensure next track is selected
|
||||
# if self.playlist.selectionModel().hasSelection():
|
||||
# row = self.playlist.currentRow()
|
||||
# track_id = int(self.playlist.item(row, 0).text())
|
||||
# if track_id == self.music.get_current_track_id():
|
||||
# # Current track highlighted: select next
|
||||
# try:
|
||||
# self.playlist.selectRow(row + 1)
|
||||
# except AttributeError:
|
||||
# # TODO
|
||||
# pass
|
||||
|
||||
def add_to_playlist(self, track):
|
||||
"""
|
||||
Add track to playlist
|
||||
|
||||
track is an instance of Track
|
||||
"""
|
||||
def update_tod_clock(self):
|
||||
"Update time of day clock"
|
||||
|
||||
DEBUG(f"add_to_playlist: track.id={track.id}")
|
||||
pl = self.playlist
|
||||
row = pl.rowCount()
|
||||
pl.insertRow(row)
|
||||
item = QTableWidgetItem(str(track.id))
|
||||
pl.setItem(row, 0, item)
|
||||
item = QTableWidgetItem(str(track.start_gap))
|
||||
pl.setItem(row, 1, item)
|
||||
item = QTableWidgetItem(track.title)
|
||||
pl.setItem(row, 2, item)
|
||||
item = QTableWidgetItem(track.artist)
|
||||
pl.setItem(row, 3, item)
|
||||
item = QTableWidgetItem(ms_to_mmss(track.duration))
|
||||
pl.setItem(row, 4, item)
|
||||
item = QTableWidgetItem(track.path)
|
||||
pl.setItem(row, 7, item)
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def disable_play_next_controls(self):
|
||||
self.actionPlay_selected.setEnabled(False)
|
||||
self.actionPlay_next.setEnabled(False)
|
||||
def update_track_headers(self):
|
||||
"Update last/current/next track header"
|
||||
|
||||
def enable_play_next_controls(self):
|
||||
self.actionPlay_selected.setEnabled(True)
|
||||
self.actionPlay_next.setEnabled(True)
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def update_track_clocks(self):
|
||||
"Update started at, silent at, clock times"
|
||||
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def update_track_timers(self):
|
||||
"Update elapsed time, etc, timers"
|
||||
|
||||
# TODO
|
||||
pass
|
||||
|
||||
class DbDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
@ -454,7 +221,7 @@ class DbDialog(QDialog):
|
||||
t = QListWidgetItem()
|
||||
t.setText(
|
||||
f"{track.title} - {track.artist} "
|
||||
f"[{ms_to_mmss(track.duration)}]"
|
||||
f"[{helpers.ms_to_mmss(track.duration)}]"
|
||||
)
|
||||
t.setData(Qt.UserRole, track.id)
|
||||
self.ui.matchList.addItem(t)
|
||||
@ -472,24 +239,6 @@ class DbDialog(QDialog):
|
||||
self.parent().add_to_playlist(track)
|
||||
|
||||
|
||||
def ms_to_mmss(ms, decimals=0, negative=False):
|
||||
if not ms:
|
||||
return "-"
|
||||
sign = ""
|
||||
if ms < 0:
|
||||
if negative:
|
||||
sign = "-"
|
||||
else:
|
||||
ms = 0
|
||||
|
||||
minutes, remainder = divmod(ms, 60 * 1000)
|
||||
seconds = remainder / 1000
|
||||
if int(seconds) == 60:
|
||||
DEBUG(f"ms_to_mmss({ms}) gave 60 seconds")
|
||||
|
||||
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
win = Window()
|
||||
|
||||
365
app/playlists.py
Normal file
365
app/playlists.py
Normal file
@ -0,0 +1,365 @@
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor, QDropEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QTableWidget,
|
||||
QAbstractItemView,
|
||||
QTableWidgetItem,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QApplication
|
||||
)
|
||||
|
||||
import helpers
|
||||
import music
|
||||
|
||||
from config import Config
|
||||
from log import DEBUG
|
||||
from model import 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
|
||||
|
||||
# UserRoles
|
||||
NEXT_TRACK = 1
|
||||
CURRENT_TRACK = 2
|
||||
COMMENT = 3
|
||||
|
||||
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.music = music.Music()
|
||||
|
||||
self.current_track = None
|
||||
self.next_track = None
|
||||
self.previous_track_path = 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()):
|
||||
name = f"playlist_col_{str(column)}_width"
|
||||
record = Settings.get_int(name)
|
||||
if record.f_int != self.columnWidth(column):
|
||||
record.update({'f_int': self.columnWidth(column)})
|
||||
|
||||
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}")
|
||||
|
||||
def face(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"
|
||||
|
||||
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.set_row_as_next_track(row):
|
||||
break
|
||||
|
||||
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.
|
||||
Cue up next track in playlist if there is one.
|
||||
Play (new) current.
|
||||
Tell database to record it as played
|
||||
Remember it was played this session
|
||||
Update playlist appearance.
|
||||
"""
|
||||
|
||||
if not self.next_track:
|
||||
return
|
||||
|
||||
if self.music.playing():
|
||||
path, position = self.music.fade()
|
||||
self.previous_track_path = path
|
||||
self.previous_track_position = position
|
||||
|
||||
self.current_track = self.next_track
|
||||
self.music.play(self.current_track.path)
|
||||
|
||||
# Find the playlist row for current track
|
||||
current_track_id = self.current_track.track_id
|
||||
self.current_track_row = None
|
||||
for row in range(self.rowCount):
|
||||
if self.item(row, 0):
|
||||
if current_track_id == int(self.item(row, 0).text()):
|
||||
self.current_track_row = row
|
||||
break
|
||||
|
||||
self.next_track = None
|
||||
|
||||
def set_next_track(self):
|
||||
"""
|
||||
Sets the selected track as the next track.
|
||||
"""
|
||||
|
||||
# TODO
|
||||
pass
|
||||
|
||||
# if self.selectionModel().hasSelection():
|
||||
# self.set_next_track_row(self.playlist.currentRow())
|
||||
# if self.selectionModel().hasSelection():
|
||||
# row = self.currentRow()
|
||||
# track_id = int(self.item(row, 0).text())
|
||||
# DEBUG(f"play_selected: track_id={track_id}")
|
||||
# # TODO: get_path may raise exception
|
||||
# music.play_track(track_id)
|
||||
# track = Tracks.get_track(id)
|
||||
# if not track:
|
||||
# ERROR(f"set_next_track({id}): can't find track")
|
||||
# return None
|
||||
|
||||
# # self.next_track['player'] = vlc.MediaPlayer(track.path)
|
||||
# self.next_track = track
|
||||
# return track.id
|
||||
|
||||
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):
|
||||
track_id = int(self.item(row, self.COL_INDEX).text())
|
||||
DEBUG(f"set_row_as_next_track: track_id={track_id}")
|
||||
self.next_track = Tracks.get_track(track_id)
|
||||
# Mark row as next track
|
||||
self.item(row, self.COL_INDEX).setData(
|
||||
Qt.UserRole, self.NEXT_TRACK)
|
||||
self.set_playlist_colours()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_playlist_colours(self):
|
||||
self.clearSelection()
|
||||
for row in range(self.rowCount()):
|
||||
if self.item(row, self.COL_INDEX):
|
||||
data = self.item(row, self.COL_INDEX).data(Qt.UserRole)
|
||||
if data == self.NEXT_TRACK:
|
||||
self.set_row_colour(
|
||||
row, QColor(Config.COLOUR_NEXT_PLAYLIST))
|
||||
elif data == self.CURRENT_TRACK:
|
||||
self.set_row_colour(
|
||||
row, QColor(Config.COLOUR_CURRENT_PLAYLIST))
|
||||
else:
|
||||
if row % 2:
|
||||
colour = QColor(Config.COLOUR_ODD_PLAYLIST)
|
||||
else:
|
||||
colour = QColor(Config.COLOUR_EVEN_PLAYLIST)
|
||||
self.set_row_colour(row, colour)
|
||||
|
||||
def set_row_colour(self, row, colour):
|
||||
for j in range(self.columnCount()):
|
||||
if self.item(row, j):
|
||||
self.item(row, j).setBackground(colour)
|
||||
|
||||
|
||||
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_())
|
||||
@ -1,114 +0,0 @@
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QDropEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QTableWidget,
|
||||
QAbstractItemView,
|
||||
QTableWidgetItem,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QApplication
|
||||
)
|
||||
|
||||
from log import DEBUG
|
||||
|
||||
|
||||
class Playlist(QTableWidget):
|
||||
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)
|
||||
|
||||
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}")
|
||||
|
||||
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 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 set_row_colour(self, row, colour):
|
||||
for j in range(self.columnCount()):
|
||||
if self.item(row, j):
|
||||
self.item(row, j).setBackground(colour)
|
||||
|
||||
|
||||
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_())
|
||||
@ -287,7 +287,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>8</number>
|
||||
<number>7</number>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderMinimumSectionSize">
|
||||
<number>0</number>
|
||||
@ -322,11 +322,6 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<string>End time</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Autoplay next</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Path</string>
|
||||
@ -694,6 +689,9 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnTrackInfo">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Track &info</string>
|
||||
</property>
|
||||
@ -732,6 +730,8 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionFade"/>
|
||||
<addaction name="actionS_top"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="action_Resume_previous"/>
|
||||
<addaction name="action_Clear_selection"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
@ -751,7 +751,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<normaloff>icon-play.png</normaloff>icon-play.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&Play selected</string>
|
||||
<string>&Play next</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Return</string>
|
||||
@ -810,12 +810,21 @@ border: 1px solid rgb(85, 87, 83);</string>
|
||||
<string>Esc</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Resume_previous">
|
||||
<property name="icon">
|
||||
<iconset>
|
||||
<normaloff>previous.png</normaloff>previous.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&Resume previous</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>Playlist</class>
|
||||
<extends>QTableWidget</extends>
|
||||
<header>promoted_classes</header>
|
||||
<header>playlists</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user