#!/usr/bin/env python import webbrowser import os import sys import urllib.parse from datetime import datetime, timedelta from log import DEBUG, EXCEPTION from slugify import slugify from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QApplication, QDialog, QFileDialog, QInputDialog, QListWidgetItem, QMainWindow, QMessageBox, ) import helpers import music from config import Config from model import Playdates, Playlists, Settings, Tracks from playlists import Playlist from songdb import add_path_to_db from ui.dlg_search_database_ui import Ui_Dialog from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist from ui.main_window_ui import Ui_MainWindow class Window(QMainWindow, Ui_MainWindow): def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) self.timer = QTimer() self.even_tick = True self.playing = False self.connect_signals_slots() self.disable_play_next_controls() self.music = music.Music() self.current_track = None self.current_track_playlist = None self.next_track = None self.next_track_playlist = None self.previous_track = None self.previous_track_position = None self.spnVolume.setValue(Config.VOLUME_VLC_DEFAULT) self.menuTest.menuAction().setVisible(Config.TESTMODE) self.set_main_window_size() self.visible_playlist = self.tabPlaylist.currentWidget self.load_last_playlists() self.enable_play_next_controls() self.timer.start(Config.TIMER_MS) 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_(): for fname in dlg.selectedFiles(): track = add_path_to_db(fname) self.visible_playlist()._add_to_playlist(track) def set_main_window_size(self): 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) def clear_selection(self): if self.visible_playlist(): self.visible_playlist().clearSelection() def closeEvent(self, event): "Don't allow window to close when a track is playing" if self.music.playing(): DEBUG("closeEvent() ignored as music is playing") event.ignore() # TODO notify user else: DEBUG("closeEvent() accepted") 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()}) event.accept() def connect_signals_slots(self): self.actionAdd_file.triggered.connect(self.add_file) self.action_Clear_selection.triggered.connect(self.clear_selection) self.actionClosePlaylist.triggered.connect(self.close_playlist) self.actionFade.triggered.connect(self.fade) self.actionNewPlaylist.triggered.connect(self.create_playlist) self.actionOpenPlaylist.triggered.connect(self.select_playlist) self.actionPlay_next.triggered.connect(self.play_next) self.actionSearch_database.triggered.connect(self.search_database) self.actionSkip_next.triggered.connect(self.play_next) self.actionSkipToEnd.triggered.connect(self.test_skip_to_end) self.actionSkipToFade.triggered.connect(self.test_skip_to_fade) self.actionTestFunction.triggered.connect(self.test_function) self.btnAddFile.clicked.connect(self.add_file) self.btnAddNote.clicked.connect(self.insert_note) self.btnDatabase.clicked.connect(self.search_database) self.btnFade.clicked.connect(self.fade) self.btnPlay.clicked.connect(self.play_next) self.btnSetNext.clicked.connect(self.set_next_track) self.btnSongfacts.clicked.connect(self.songfacts_search) self.btnStop.clicked.connect(self.stop) self.btnWikipedia.clicked.connect(self.wikipedia_search) self.spnVolume.valueChanged.connect(self.change_volume) self.tabPlaylist.currentChanged.connect(self.tab_change) self.timer.timeout.connect(self.tick) def create_playlist(self): "Create new playlist" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Playlist name:") dlg.resize(500, 100) ok = dlg.exec() if ok: playlist = Playlists.new(dlg.textValue()) self.load_playlist(playlist) def change_volume(self, volume): "Change player maximum volume" DEBUG(f"change_volume({volume})") self.music.set_volume(volume) def close_playlist(self): self.visible_playlist().db.close() index = self.tabPlaylist.currentIndex() self.tabPlaylist.removeTab(index) def disable_play_next_controls(self): DEBUG("disable_play_next_controls()") self.actionPlay_next.setEnabled(False) def enable_play_next_controls(self): DEBUG("enable_play_next_controls()") self.actionPlay_next.setEnabled(True) def fade(self): "Fade currently playing track" if not self.current_track: return self.previous_track = self.current_track self.previous_track_position = self.music.fade() def insert_note(self): "Add non-track row to playlist" dlg = QInputDialog(self) dlg.setInputMode(QInputDialog.TextInput) dlg.setLabelText("Note:") dlg.resize(500, 100) ok = dlg.exec() if ok: self.visible_playlist().add_note(dlg.textValue()) def load_last_playlists(self): "Load the playlists that we loaded at end of last session" for playlist in Playlists.get_last_used(): DEBUG(f"load_last_playlists(), {playlist.name=}, {playlist.id=}") self.load_playlist(playlist) def load_playlist(self, playlist_db): """ Take the passed database object, create a playlist display, attach the database object, get it populated and then add tab. """ playlist_table = Playlist() playlist_table.db = playlist_db playlist_table.populate() self.tabPlaylist.addTab(playlist_table, playlist_db.name) 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 DEBUG( "play_next(), " f"next_track={self.next_track.title if self.next_track else None} " "current_track=" f"{self.current_track.title if self.current_track else None}" ) # Stop current track, if any self.stop_playing() # Play next track self.current_track = self.next_track self.current_track_playlist = self.next_track_playlist self.next_track = None self.next_track_playlist = None self.music.play(self.current_track.path) # Update metadata next_track_id = self.current_track_playlist.play_started() if next_track_id is not None: self.next_track = Tracks.get_track(next_track_id) self.next_track_playlist = self.current_track_playlist # Check we can read it if not os.access(self.next_track.path, os.R_OK): self.show_warning( "Can't read next track", self.next_track.path) else: self.next_track = self.next_track_playlist = None # Tell database to record it as played self.current_track.update_lastplayed() Playdates.add_playdate(self.current_track) self.disable_play_next_controls() self.update_headers() # Set time clocks now = datetime.now() self.label_start_tod.setText(now.strftime("%H:%M:%S")) silence_at = self.current_track.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(helpers.ms_to_mmss( silence_at - self.current_track.fade_at )) def play_previous(self): "Resume playing last track" # TODO pass def search_database(self): dlg = DbDialog(self) dlg.exec() def select_playlist(self): # TODO don't show those that are currently open dlg = SelectPlaylistDialog(self) dlg.exec() playlist = Playlists.get_playlist_by_id(dlg.plid) self.load_playlist(playlist) def set_next_track(self): "Set selected track as next" next_track_id = self.visible_playlist().set_selected_as_next() if next_track_id: if self.next_track_playlist: self.next_track_playlist.clear_next() self.next_track_playlist = self.visible_playlist() self.next_track = Tracks.get_track(next_track_id) self.update_headers() def show_warning(self, title, msg): "Display a warning to user" QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel) def songfacts_search(self): "Open a browser window in Songfacts searching for selected title" title = self.visible_playlist().get_selected_title() if title: slug = slugify(title, replacements=([["'", ""]])) url = f"https://www.songfacts.com/search/songs/{slug}" webbrowser.open(url, new=2) def stop(self): "Stop playing immediately" DEBUG("musicmuster.stop()") self.stop_playing(fade=False) def stop_playing(self, fade=True): "Stop playing current track" DEBUG("musicmuster.stop_playing()") if not self.music.playing(): return self.previous_track_position = self.music.get_position() if fade: self.music.fade() else: self.music.stop() self.current_track_playlist.clear_current() # Shuffle tracks along self.previous_track = self.current_track self.update_headers() def tab_change(self): "User has changed tabs, so refresh next track" pass def test_function(self): "Placeholder for test function" import ipdb ipdb.set_trace() def test_skip_to_end(self): "Skip current track to 1 second before silence" if not self.playing(): return self.music.set_position(self.get_current_silence_at() - 1000) def test_skip_to_fade(self): "Skip current track to 1 second before fade" if not self.music.playing(): return self.music.set_position(self.get_current_fade_at() - 1000) def tick(self): """ Update screen The Time of Day clock is updated every tick (500ms). All other timers are updated every second. As the timers have a one-second resolution, updating every 500ms can result in some timers updating and then, 500ms later, other timers updating. That looks odd. """ now = datetime.now() self.lblTOD.setText(now.strftime("%H:%M:%S")) self.even_tick = not self.even_tick if not self.even_tick: return if self.music.playing(): self.playing = True playtime = self.music.get_playtime() time_to_fade = (self.current_track.fade_at - playtime) time_to_silence = (self.current_track.silence_at - playtime) time_to_end = (self.current_track.duration - playtime) # Elapsed time if time_to_end < 500: self.label_elapsed_timer.setText( helpers.ms_to_mmss(self.current_track.duration) ) else: self.label_elapsed_timer.setText(helpers.ms_to_mmss(playtime)) # Time to fade self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade)) # Time to silence if time_to_silence < 5000: self.frame_silent.setStyleSheet( f"background: {Config.COLOUR_ENDING_TIMER}" ) self.enable_play_next_controls() elif time_to_fade < 500: self.frame_silent.setStyleSheet( f"background: {Config.COLOUR_WARNING_TIMER}" ) self.enable_play_next_controls() else: self.frame_silent.setStyleSheet("") self.label_silent_timer.setText( helpers.ms_to_mmss(time_to_silence) ) # Time to end self.label_end_timer.setText(helpers.ms_to_mmss(time_to_end)) else: if self.playing: self.label_end_timer.setText("00:00") self.frame_silent.setStyleSheet("") self.current_track_playlist.play_stopped() self.playing = False self.previous_track = self.current_track self.current_track = None self.current_track_playlist = None self.previous_track_position = 0 self.update_headers() def update_headers(self): "Update last / current / next track headers" try: self.hdrPreviousTrack.setText( f"{self.previous_track.title} - " f"{self.previous_track.artist}" ) except AttributeError: self.hdrPreviousTrack.setText("") try: self.hdrCurrentTrack.setText( f"{self.current_track.title} - " f"{self.current_track.artist}" ) except AttributeError: self.hdrCurrentTrack.setText("") try: self.hdrNextTrack.setText( f"{self.next_track.title} - " f"{self.next_track.artist}" ) except AttributeError: self.hdrNextTrack.setText("") def wikipedia_search(self): "Open a browser window in Wikipedia searching for selected title" title = self.visible_playlist().get_selected_title() if title: str = urllib.parse.quote_plus(title) url = f"https://www.wikipedia.org/w/index.php?search={str}" webbrowser.open(url, new=2) 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.double_click) self.ui.btnAdd.clicked.connect(self.add_selected) self.ui.btnAddClose.clicked.connect(self.add_selected_and_close) self.ui.btnClose.clicked.connect(self.close) self.ui.matchList.itemSelectionChanged.connect(self.selection_changed) 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 add_selected(self): if not self.ui.matchList.selectedItems(): return item = self.ui.matchList.currentItem() track_id = item.data(Qt.UserRole) self.add_track(track_id) def add_selected_and_close(self): self.add_selected() self.close() 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 double_click(self, entry): track_id = entry.data(Qt.UserRole) self.add_track(track_id) # Select search text to make it easier for next search self.select_searchtext() def add_track(self, track_id): track = Tracks.track_from_id(track_id) self.parent().visible_playlist()._add_to_playlist(track) # Select search text to make it easier for next search self.select_searchtext() def select_searchtext(self): self.ui.searchString.selectAll() self.ui.searchString.setFocus() def selection_changed(self): if not self.ui.matchList.selectedItems(): return item = self.ui.matchList.currentItem() track_id = item.data(Qt.UserRole) self.ui.dbPath.setText(Tracks.get_path(track_id)) 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.list_doubleclick) self.ui.buttonBox.accepted.connect(self.open) self.ui.buttonBox.rejected.connect(self.close) record = Settings.get_int("select_playlist_dialog_width") width = record.f_int or 800 record = Settings.get_int("select_playlist_dialog_height") height = record.f_int or 600 self.resize(width, height) 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 __del__(self): record = Settings.get_int("select_playlist_dialog_height") if record.f_int != self.height(): record.update({'f_int': self.height()}) record = Settings.get_int("select_playlist_dialog_width") if record.f_int != self.width(): record.update({'f_int': self.width()}) def list_doubleclick(self, entry): self.plid = entry.data(Qt.UserRole) self.accept() def open(self): if self.ui.lstPlaylists.selectedItems(): item = self.ui.lstPlaylists.currentItem() self.plid = item.data(Qt.UserRole) self.accept() def main(): try: app = QApplication(sys.argv) win = Window() win.show() sys.exit(app.exec()) except Exception: EXCEPTION("Unhandled Exception caught by musicmuster.main()") if __name__ == "__main__": main()