musicmuster/app/musicmuster.py
2021-03-30 19:42:39 +01:00

455 lines
15 KiB
Python
Executable File

#!/usr/bin/python3
import vlc
import sys
import threading
from datetime import datetime, timedelta
from log import DEBUG, ERROR
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import QApplication, QDialog, QMainWindow
from PyQt5.QtWidgets import QTableWidgetItem, QFileDialog, QListWidgetItem
from ui.main_window_ui import Ui_MainWindow
from ui.dlg_search_database_ui import Ui_Dialog
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 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
class Window(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(self)
self.timer = QTimer()
self.connectSignalsSlots()
self.music = Music()
self.disable_play_next_controls()
record = Settings.get_int("mainwindow_x")
x = record.f_int or 1
record = Settings.get_int("mainwindow_y")
y = record.f_int or 1
record = Settings.get_int("mainwindow_width")
width = record.f_int or 1599
record = Settings.get_int("mainwindow_height")
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)
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()})
record = Settings.get_int("mainwindow_width")
if record.f_int != self.width():
record.update({'f_int': self.width()})
record = Settings.get_int("mainwindow_x")
if record.f_int != self.x():
record.update({'f_int': self.x()})
record = Settings.get_int("mainwindow_y")
if record.f_int != self.y():
record.update({'f_int': self.y()})
def connectSignalsSlots(self):
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.btnSkipNext.clicked.connect(self.play_next)
self.btnStop.clicked.connect(self.fade)
self.playlist.itemSelectionChanged.connect(self.set_next_track)
self.timer.timeout.connect(self.tick)
def selectFromDatabase(self):
dlg = DbDialog(self)
dlg.exec()
def fade(self):
self.music.fade_current()
def play_next(self):
self.music.play_next()
self.current_track.setText(
f"{self.music.get_current_title()} - "
f"{self.music.get_current_artist()}"
)
self.previous_track.setText(
f"{self.music.get_previous_title()} - "
f"{self.music.get_previous_artist()}"
)
self.set_next_track()
# Set time clocks
now = datetime.now()
self.label_start_tod.setText(now.strftime("%H:%M:%S"))
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(
silence_at - self.music.get_current_fade_at()))
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()
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):
"""
Set the next track. In order of priority:
- the highlighted track so long as it's not the current track
- if the current track is highlighted, the next track if there
is one
- none, in which case disable play next controls
"""
track_id = None
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: get next if it exists
try:
track_id = int(self.playlist.item(row + 1, 0).text())
except AttributeError:
# There is no next track
track_id = None
if track_id:
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()
else:
self.next_track.setText("")
self.disable_play_next_controls()
def tick(self):
# 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
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}")
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)
def disable_play_next_controls(self):
self.actionPlay_selected.setEnabled(False)
self.actionPlay_next.setEnabled(False)
def enable_play_next_controls(self):
self.actionPlay_selected.setEnabled(True)
self.actionPlay_next.setEnabled(True)
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"[{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_only_playlist()
# 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)
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()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()