From 7d71e8ce64de34cadb1ce90c596113e737f4e8f3 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Fri, 12 Aug 2022 21:25:59 +0100 Subject: [PATCH] WIP: clocks working --- app/config.py | 1 + app/dbconfig.py | 5 +- app/helpers.py | 16 +- app/models.py | 58 +- app/music.py | 298 ++++--- app/musicmuster.py | 740 +++++++++--------- app/playlists.py | 406 +++++----- app/ui_helpers.py | 24 +- app/utilities.py | 6 +- ...90f8_add_played_column_to_playlist_rows.py | 32 + 10 files changed, 789 insertions(+), 797 deletions(-) create mode 100644 migrations/versions/0c604bf490f8_add_played_column_to_playlist_rows.py diff --git a/app/config.py b/app/config.py index c32cc21..683af2f 100644 --- a/app/config.py +++ b/app/config.py @@ -42,6 +42,7 @@ class Config(object): FADE_TIME = 3000 INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s" + LAST_PLAYED_TODAY_STRING = "Today" LOG_LEVEL_STDERR = logging.DEBUG LOG_LEVEL_SYSLOG = logging.DEBUG LOG_NAME = "musicmuster" diff --git a/app/dbconfig.py b/app/dbconfig.py index c769170..22ec2af 100644 --- a/app/dbconfig.py +++ b/app/dbconfig.py @@ -45,8 +45,9 @@ def Session() -> Generator[scoped_session, None, None]: function = frame.function lineno = frame.lineno Session = scoped_session(sessionmaker(bind=engine, future=True)) - log.debug(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}") + # log.debug(f"Session acquired, {file=}, {function=}, + # function{lineno=}, {Session=}") yield Session - log.debug(" Session released") + # log.debug(" Session released") Session.commit() Session.close() diff --git a/app/helpers.py b/app/helpers.py index 080372a..e4b480d 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -4,7 +4,7 @@ import psutil from config import Config from datetime import datetime from pydub import AudioSegment -# from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtWidgets import QMessageBox # from tinytag import TinyTag from typing import Optional # from typing import Dict, Optional, Union @@ -68,7 +68,7 @@ def get_audio_segment(path: str) -> Optional[AudioSegment]: if path.endswith('.mp3'): return AudioSegment.from_mp3(path) elif path.endswith('.flac'): - return AudioSegment.from_file(path, "flac") + return AudioSegment.from_file(path, "flac") # type: ignore except AttributeError: return None @@ -232,12 +232,12 @@ def open_in_audacity(path: str) -> bool: do_command(f'Import2: Filename="{path}"') return True -# -# -# def show_warning(title: str, msg: str) -> None: -# """Display a warning to user""" -# -# QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel) + + +def show_warning(title: str, msg: str) -> None: + """Display a warning to user""" + + QMessageBox.warning(None, title, msg, buttons=QMessageBox.Cancel) def trailing_silence( diff --git a/app/models.py b/app/models.py index e37c682..1f2c3f1 100644 --- a/app/models.py +++ b/app/models.py @@ -225,16 +225,14 @@ class Playdates(Base): f"" ) -# -# def __init__(self, session: Session, track_id: int) -> None: -# """Record that track was played""" -# -# log.debug(f"add_playdate({track_id=})") -# -# self.lastplayed = datetime.now() -# self.track_id = track_id -# session.add(self) -# session.flush() + + def __init__(self, session: Session, track_id: int) -> None: + """Record that track was played""" + + self.lastplayed = datetime.now() + self.track_id = track_id + session.add(self) + session.commit() @staticmethod def last_played(session: Session, track_id: int) -> Optional[datetime]: @@ -430,6 +428,7 @@ class PlaylistRows(Base): playlist = relationship(Playlists, back_populates="rows") track_id = Column(Integer, ForeignKey('tracks.id'), nullable=True) track = relationship("Tracks", back_populates="playlistrows") + played = Column(Boolean, nullable=False, index=False, default=False) def __repr__(self) -> str: return ( @@ -502,12 +501,49 @@ class PlaylistRows(Base): ).scalars().all() for i, plr in enumerate(plrs): - print(f"{i=}, {plr.row_number=}") plr.row_number = i # Ensure new row numbers are available to the caller session.commit() + @staticmethod + def get_played_rows(session: Session, + playlist_id: int) -> List[int]: + """ + For passed playlist, return a list of row numbers that + have been played. + """ + + plrs = session.execute( + select(PlaylistRows.row_number) + .where( + PlaylistRows.playlist_id == playlist_id, + PlaylistRows.played.is_(True) + ) + .order_by(PlaylistRows.row_number) + ).scalars().all() + + return plrs + + @staticmethod + def get_rows_with_tracks(session: Session, + playlist_id: int) -> List[int]: + """ + For passed playlist, return a list of all row numbers that + contain tracks + """ + + plrs = session.execute( + select(PlaylistRows.row_number) + .where( + PlaylistRows.playlist_id == playlist_id, + PlaylistRows.track_id.is_not(None) + ) + .order_by(PlaylistRows.row_number) + ).scalars().all() + + return plrs + @staticmethod def move_to_playlist(session: Session, playlistrow_ids: List[int], diff --git a/app/music.py b/app/music.py index 2789209..563ecae 100644 --- a/app/music.py +++ b/app/music.py @@ -1,151 +1,126 @@ # import os -# import threading -# import vlc +import threading +import vlc # -# from config import Config -# from datetime import datetime -# from time import sleep -# -# from log import log.debug, log.error -# -# lock = threading.Lock() -# -# -# class Music: -# """ -# Manage the playing of music tracks -# """ -# -# def __init__(self): -# self.current_track_start_time = None -# self.fading = 0 -# self.VLC = vlc.Instance() -# self.player = None -# self.track_path = None -# self.max_volume = Config.VOLUME_VLC_DEFAULT -# -# def fade(self): -# """ -# Fade the currently playing track. -# -# The actual management of fading runs in its own thread so as not -# to hold up the UI during the fade. -# """ -# -# log.debug("music.fade()", True) -# -# if not self.player: -# return -# -# if not self.player.get_position() > 0 and self.player.is_playing(): -# return -# -# self.fading += 1 -# -# thread = threading.Thread(target=self._fade) -# thread.start() -# -# def _fade(self): -# """ -# Implementation of fading the current track in a separate thread. -# """ -# -# # Take a copy of current player to allow another track to be -# # started without interfering here -# -# log.debug(f"music._fade(), {self.player=}", True) -# -# with lock: -# p = self.player -# self.player = None -# -# log.debug("music._fade() post-lock", True) -# -# fade_time = Config.FADE_TIME / 1000 -# steps = Config.FADE_STEPS -# sleep_time = fade_time / steps -# -# # We reduce volume by one mesure first, then by two measures, -# # then three, and so on. -# -# # The sum of the arithmetic sequence 1, 2, 3, ..n is -# # (n**2 + n) / 2 -# total_measures_count = (steps**2 + steps) / 2 -# -# measures_to_reduce_by = 0 -# for i in range(1, steps + 1): -# measures_to_reduce_by += i -# volume_factor = 1 - ( -# measures_to_reduce_by / total_measures_count) -# p.audio_set_volume(int(self.max_volume * volume_factor)) -# sleep(sleep_time) -# -# with lock: -# log.debug(f"music._fade(), stopping {p=}", True) -# -# p.stop() -# log.debug(f"Releasing player {p=}", True) -# p.release() -# -# self.fading -= 1 -# -# def get_playtime(self): -# """Return elapsed play time""" -# -# with lock: -# if not self.player: -# return None -# -# return self.player.get_time() -# -# def get_position(self): -# """Return current position""" -# -# with lock: -# log.debug("music.get_position", True) -# -# print(f"get_position, {self.player=}") -# if not self.player: -# return -# return self.player.get_position() -# -# def play(self, path): -# """ -# Start playing the track at path. -# -# Log and return if path not found. -# """ -# -# log.debug(f"music.play({path=})", True) -# -# if not os.access(path, os.R_OK): -# log.error(f"play({path}): path not found") -# return -# -# self.track_path = path -# -# self.player = self.VLC.media_player_new(path) -# self.player.audio_set_volume(self.max_volume) -# log.debug(f"music.play({path=}), {self.player}", True) -# self.player.play() -# self.current_track_start_time = datetime.now() -# -# def playing(self): -# """ -# Return True if currently playing a track, else False -# -# vlc.is_playing() returns True if track was faded out. -# get_position seems more reliable. -# """ -# -# with lock: -# if self.player: -# if self.player.get_position() > 0 and self.player.is_playing(): -# return True -# -# # We take a copy of the player when fading, so we could be -# # playing in a fade nowFalse -# return self.fading > 0 +from config import Config +from datetime import datetime +from helpers import file_is_readable +from typing import Optional +from time import sleep + +from log import log + +lock = threading.Lock() + + +class Music: + """ + Manage the playing of music tracks + """ + + def __init__(self) -> None: + # self.current_track_start_time = None + # self.fading = 0 + self.VLC = vlc.Instance() + self.player = None + # self.track_path = None + self.max_volume = Config.VOLUME_VLC_DEFAULT + + def fade(self) -> None: + """ + Fade the currently playing track. + + The actual management of fading runs in its own thread so as not + to hold up the UI during the fade. + """ + + if not self.player: + return + + if not self.player.get_position() > 0 and self.player.is_playing(): + return + + thread = threading.Thread(target=self._fade) + thread.start() + + def _fade(self) -> None: + """ + Implementation of fading the current track in a separate thread. + """ + + # Take a copy of current player to allow another track to be + # started without interfering here + with lock: + p = self.player + self.player = None + + # Sanity check + if not p: + return + + fade_time = Config.FADE_TIME / 1000 + steps = Config.FADE_STEPS + sleep_time = fade_time / steps + + # We reduce volume by one mesure first, then by two measures, + # then three, and so on. + # The sum of the arithmetic sequence 1, 2, 3, ..n is + # (n**2 + n) / 2 + total_measures_count = (steps**2 + steps) / 2 + + measures_to_reduce_by = 0 + for i in range(1, steps + 1): + measures_to_reduce_by += i + volume_factor = 1 - ( + measures_to_reduce_by / total_measures_count) + p.audio_set_volume(int(self.max_volume * volume_factor)) + sleep(sleep_time) + + with lock: + p.stop() + log.debug(f"Releasing player {p=}") + p.release() + + def get_playtime(self) -> Optional[int]: + """Return elapsed play time""" + + if not self.player: + return None + + return self.player.get_time() + + def get_position(self) -> Optional[float]: + """Return current position""" + + if not self.player: + return None + return self.player.get_position() + + def play(self, path: str, + position: Optional[float] = None) -> Optional[int]: + """ + Start playing the track at path. + + Log and return if path not found. + """ + + if not file_is_readable(path): + log.error(f"play({path}): path not readable") + return None + + status = -1 + self.track_path = path + + self.player = self.VLC.media_player_new(path) + if self.player: + self.player.audio_set_volume(self.max_volume) + self.current_track_start_time = datetime.now() + status = self.player.play() + if position: + self.player.set_position(position) + + return status + # # def set_position(self, ms): # """Set current play time in milliseconds from start""" @@ -164,20 +139,17 @@ # self.max_volume = volume # # self.player.audio_set_volume(volume) -# -# def stop(self): -# """Immediately stop playing""" -# -# log.debug(f"music.stop(), {self.player=}", True) -# -# with lock: -# if not self.player: -# return -# -# position = self.player.get_position() -# self.player.stop() -# log.debug(f"music.stop(): Releasing player {self.player=}", True) -# self.player.release() -# # Ensure we don't reference player after release -# self.player = None -# return position + + def stop(self) -> float: + """Immediately stop playing""" + + with lock: + if not self.player: + return 0.0 + + position = self.player.get_position() + self.player.stop() + self.player.release() + # Ensure we don't reference player after release + self.player = None + return position diff --git a/app/musicmuster.py b/app/musicmuster.py index 92d09dd..f960436 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -8,11 +8,11 @@ import sys # import webbrowser # # -# from datetime import datetime, timedelta +from datetime import datetime, timedelta # from typing import Callable, Dict, List, Optional, Tuple # -# from PyQt5.QtCore import QDate, QEvent, QProcess, Qt, QTime, QTimer, QUrl -from PyQt5.QtCore import Qt +# from PyQt5.QtCore import QDate, QProcess, Qt, QTime, QTimer, QUrl +from PyQt5.QtCore import QEvent, Qt, QTimer from PyQt5.QtGui import QColor from PyQt5.QtWidgets import ( QApplication, @@ -27,12 +27,12 @@ from PyQt5.QtWidgets import ( ) # from dbconfig import engine, Session -# import helpers -# import music +import helpers +import music # from models import ( Base, - # Playdates, + Playdates, PlaylistRows, Playlists, Settings, @@ -41,14 +41,13 @@ from models import ( from playlists import PlaylistTab from sqlalchemy.orm.exc import DetachedInstanceError # from ui.dlg_search_database_ui import Ui_Dialog -from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist +from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore # from ui.downloadcsv_ui import Ui_DateSelect from config import Config -from ui.main_window_ui import Ui_MainWindow +from ui.main_window_ui import Ui_MainWindow # type: ignore # from utilities import create_track_from_file, update_db -# -# -# log = logging.getLogger(Config.LOG_NAME) + + class TrackData: def __init__(self, track): self.id = track.id @@ -67,12 +66,11 @@ class Window(QMainWindow, Ui_MainWindow): super().__init__(parent) self.setupUi(self) -# self.timer: QTimer = QTimer() -# self.even_tick: bool = True -# self.playing: bool = False -# self.disable_play_next_controls() -# -# self.music: music.Music = music.Music() + self.timer: QTimer = QTimer() + self.even_tick: bool = True + self.playing: bool = False + + self.music: music.Music = music.Music() self.current_track: Optional[TrackData] = None self.current_track_playlist_tab: Optional[PlaylistTab] = None self.next_track: Optional[TrackData] = None @@ -86,16 +84,16 @@ class Window(QMainWindow, Ui_MainWindow): # self.txtSearch = QLineEdit() # self.statusbar.addWidget(self.txtSearch) # self.txtSearch.setHidden(True) -# self.hide_played_tracks = False + self.hide_played_tracks = False # self.splitter.setSizes([200, 200]) self.visible_playlist_tab: Callable[[], PlaylistTab] = \ self.tabPlaylist.currentWidget -# + self._load_last_playlists() -# self.enable_play_next_controls() + self.enable_play_next_controls() # self.check_audacity() -# self.timer.start(Config.TIMER_MS) + self.timer.start(Config.TIMER_MS) self.connect_signals_slots() # # def set_main_window_size(self) -> None: @@ -137,61 +135,65 @@ class Window(QMainWindow, Ui_MainWindow): if self.visible_playlist_tab(): self.visible_playlist_tab().clear_selection() -# -# def closeEvent(self, event: QEvent) -> None: -# """Don't allow window to close when a track is playing""" -# -# if self.music.playing(): -# log.debug("closeEvent() ignored as music is playing") -# event.ignore() -# helpers.show_warning( -# "Track playing", -# "Can't close application while track is playing") -# else: -# log.debug("closeEvent() accepted") -# -# with Session() as session: -# record = Settings.get_int_settings( -# session, "mainwindow_height") -# if record.f_int != self.height(): -# record.update(session, {'f_int': self.height()}) -# -# record = Settings.get_int_settings(session, "mainwindow_width") -# if record.f_int != self.width(): -# record.update(session, {'f_int': self.width()}) -# -# record = Settings.get_int_settings(session, "mainwindow_x") -# if record.f_int != self.x(): -# record.update(session, {'f_int': self.x()}) -# -# record = Settings.get_int_settings(session, "mainwindow_y") -# if record.f_int != self.y(): -# record.update(session, {'f_int': self.y()}) -# -# # Find a playlist tab (as opposed to an info tab) and -# # save column widths -# if self.current_track_playlist_tab: -# self.current_track_playlist_tab.close() -# elif self.next_track_playlist_tab: -# self.next_track_playlist_tab.close() -# -# event.accept() + + def closeEvent(self, event: QEvent) -> None: + """Handle attempt to close main window""" + + # Don't allow window to close when a track is playing + if self.music.player and self.music.player.is_playing(): + event.ignore() + helpers.show_warning( + "Track playing", + "Can't close application while track is playing") + else: + with Session() as session: + record = Settings.get_int_settings( + session, "mainwindow_height") + if record.f_int != self.height(): + record.update(session, {'f_int': self.height()}) + + record = Settings.get_int_settings(session, "mainwindow_width") + if record.f_int != self.width(): + record.update(session, {'f_int': self.width()}) + + record = Settings.get_int_settings(session, "mainwindow_x") + if record.f_int != self.x(): + record.update(session, {'f_int': self.x()}) + + record = Settings.get_int_settings(session, "mainwindow_y") + if record.f_int != self.y(): + record.update(session, {'f_int': self.y()}) + + # Save splitter settings + splitter_sizes = self.splitter.sizes() + assert len(splitter_sizes) == 2 + splitter_top, splitter_bottom = splitter_sizes + + record = Settings.get_int_settings(session, "splitter_top") + if record.f_int != splitter_top: + record.update(session, {'f_int': splitter_top}) + + record = Settings.get_int_settings(session, "splitter_bottom") + if record.f_int != splitter_bottom: + record.update(session, {'f_int': splitter_bottom}) + + event.accept() def connect_signals_slots(self) -> None: -# self.actionAdd_note.triggered.connect(self.create_note) + # self.actionAdd_note.triggered.connect(self.create_note) self.action_Clear_selection.triggered.connect(self.clear_selection) # self.actionClosePlaylist.triggered.connect(self.close_playlist_tab) # self.actionDownload_CSV_of_played_tracks.triggered.connect( # self.download_played_tracks) -# self.actionEnable_controls.triggered.connect( -# self.enable_play_next_controls) + self.actionEnable_controls.triggered.connect( + self.enable_play_next_controls) # self.actionExport_playlist.triggered.connect(self.export_playlist_tab) # self.actionImport.triggered.connect(self.import_track) -# self.actionFade.triggered.connect(self.fade) + self.actionFade.triggered.connect(self.fade) # self.actionMoveSelected.triggered.connect(self.move_selected) # self.actionNewPlaylist.triggered.connect(self.create_playlist) # self.actionOpenPlaylist.triggered.connect(self.open_playlist) -# self.actionPlay_next.triggered.connect(self.play_next) + self.actionPlay_next.triggered.connect(self.play_next) # self.actionSearch.triggered.connect(self.search_playlist) # self.actionSearch_database.triggered.connect(self.search_database) # self.actionSelect_next_track.triggered.connect(self.select_next_row) @@ -200,25 +202,19 @@ class Window(QMainWindow, Ui_MainWindow): # self.select_previous_row) # self.actionSelect_unplayed_tracks.triggered.connect( # self.select_unplayed) -# self.actionSetNext.triggered.connect( -# lambda: self.tabPlaylist.currentWidget().set_selected_as_next()) + self.actionSetNext.triggered.connect( + lambda: self.tabPlaylist.currentWidget().set_selected_as_next()) # self.actionSkip_next.triggered.connect(self.play_next) -# self.actionStop.triggered.connect(self.stop) -# # self.btnAddNote.clicked.connect(self.create_note) -# # self.btnDatabase.clicked.connect(self.search_database) + self.actionStop.triggered.connect(self.stop) # self.btnDrop3db.clicked.connect(self.drop3db) # self.btnHidePlayed.clicked.connect(self.hide_played) -# self.btnFade.clicked.connect(self.fade) -# # self.btnPlay.clicked.connect(self.play_next) -# # self.btnSetNext.clicked.connect( -# # lambda: self.tabPlaylist.currentWidget().set_selected_as_next()) -# # self.btnSongInfo.clicked.connect(self.song_info_search) -# self.btnStop.clicked.connect(self.stop) + self.btnFade.clicked.connect(self.fade) + self.btnStop.clicked.connect(self.stop) # self.tabPlaylist.tabCloseRequested.connect(self.close_tab) # self.txtSearch.returnPressed.connect(self.search_playlist_return) # self.txtSearch.textChanged.connect(self.search_playlist_update) # -# self.timer.timeout.connect(self.tick) + self.timer.timeout.connect(self.tick) # # def create_playlist(self) -> None: # """Create new playlist""" @@ -275,19 +271,18 @@ class Window(QMainWindow, Ui_MainWindow): add tab to display. """ - playlist_tab: PlaylistTab = PlaylistTab( + playlist_tab = PlaylistTab( musicmuster=self, session=session, playlist_id=playlist.id) - idx: int = self.tabPlaylist.addTab(playlist_tab, playlist.name) + idx = self.tabPlaylist.addTab(playlist_tab, playlist.name) self.tabPlaylist.setCurrentIndex(idx) -# -# def disable_play_next_controls(self) -> None: -# """ -# Disable "play next" keyboard controls -# """ -# -# log.debug("disable_play_next_controls()") -# self.actionPlay_next.setEnabled(False) -# self.statusbar.showMessage("Play controls: Disabled", 0) + + def disable_play_next_controls(self) -> None: + """ + Disable "play next" keyboard controls + """ + + self.actionPlay_next.setEnabled(False) + self.statusbar.showMessage("Play controls: Disabled", 0) # # def download_played_tracks(self) -> None: # """Download a CSV of played tracks""" @@ -322,62 +317,61 @@ class Window(QMainWindow, Ui_MainWindow): # self.music.set_volume(Config.VOLUME_VLC_DROP3db, set_default=False) # else: # self.music.set_volume(Config.VOLUME_VLC_DEFAULT, set_default=False) -# -# def enable_play_next_controls(self) -> None: -# """ -# Enable "play next" keyboard controls -# """ -# -# log.debug("enable_play_next_controls()") -# self.actionPlay_next.setEnabled(True) -# self.statusbar.showMessage("Play controls: Enabled", 0) -# -# def end_of_track_actions(self) -> None: -# """ -# Clean up after track played -# -# Actions required: -# - Set flag to say we're not playing a track -# - Reset current track -# - Tell playlist_tab track has finished -# - Reset current playlist_tab -# - Reset clocks -# - Update headers -# - Enable controls -# """ -# -# # Set flag to say we're not playing a track so that tick() -# # doesn't see player=None and kick off end-of-track actions -# self.playing = False -# -# # Reset current track -# if self.current_track: -# self.previous_track = self.current_track -# self.current_track = None -# -# # Tell playlist_tab track has finished and -# # reset current playlist_tab -# if self.current_track_playlist_tab: -# self.current_track_playlist_tab.play_stopped() -# self.current_track_playlist_tab = None -# -# # Reset clocks -# self.frame_fade.setStyleSheet("") -# self.frame_silent.setStyleSheet("") -# self.label_elapsed_timer.setText("00:00") -# self.label_end_timer.setText("00:00") -# self.label_fade_length.setText("0:00") -# self.label_fade_timer.setText("00:00") -# self.label_silent_timer.setText("00:00") -# self.label_track_length.setText("0:00") -# self.label_start_time.setText("00:00:00") -# self.label_end_time.setText("00:00:00") -# -# # Update headers -# self.update_headers() -# -# # Enable controls -# self.enable_play_next_controls() + + def enable_play_next_controls(self) -> None: + """ + Enable "play next" keyboard controls + """ + + self.actionPlay_next.setEnabled(True) + self.statusbar.showMessage("Play controls: Enabled", 0) + + def end_of_track_actions(self) -> None: + """ + Clean up after track played + + Actions required: + - Set flag to say we're not playing a track + - Reset current track + - Tell playlist_tab track has finished + - Reset current playlist_tab + - Reset clocks + - Update headers + - Enable controls + """ + + # Set flag to say we're not playing a track so that tick() + # doesn't see player=None and kick off end-of-track actions + self.playing = False + + # Reset current track + if self.current_track: + self.previous_track = self.current_track + self.current_track = None + + # Tell playlist_tab track has finished and + # reset current playlist_tab + if self.current_track_playlist_tab: + self.current_track_playlist_tab.play_stopped() + self.current_track_playlist_tab = None + + # Reset clocks + self.frame_fade.setStyleSheet("") + self.frame_silent.setStyleSheet("") + self.label_elapsed_timer.setText("00:00") + self.label_end_timer.setText("00:00") + self.label_fade_length.setText("0:00") + self.label_fade_timer.setText("00:00") + self.label_silent_timer.setText("00:00") + self.label_track_length.setText("0:00") + self.label_start_time.setText("00:00:00") + self.label_end_time.setText("00:00:00") + + # Update headers + self.update_headers() + + # Enable controls + self.enable_play_next_controls() # # def export_playlist_tab(self) -> None: # """Export the current playlist to an m3u file""" @@ -414,13 +408,11 @@ class Window(QMainWindow, Ui_MainWindow): # f"{track.path}" # "\n" # ) -# -# def fade(self) -> None: -# """Fade currently playing track""" -# -# log.debug("musicmuster:fade()", True) -# -# self.stop_playing(fade=True) + + def fade(self) -> None: + """Fade currently playing track""" + + self.stop_playing(fade=True) # # def hide_played(self): # """Toggle hide played tracks""" @@ -553,103 +545,91 @@ class Window(QMainWindow, Ui_MainWindow): if destination_visible_playlist_tab: destination_visible_playlist_tab.populate( session, dlg.playlist.id) -# -# def play_next(self) -> None: -# """ -# Play next track. -# -# Actions required: -# - If there is no next track set, return. -# - If there's currently a track playing, fade it. -# - Move next track to current track. -# - Update record of current track playlist_tab -# - If current track on different playlist_tab to last, reset -# last track playlist_tab colour -# - Set current track playlist_tab colour -# - Restore volume if -3dB active -# - Play (new) current track. -# - Tell database to record it as played -# - Tell playlist track is now playing -# - Disable play next controls -# - Update headers -# - Update clocks -# """ -# -# log.debug( -# "musicmuster.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}", -# True -# ) -# -# # If there is no next track set, return. -# if not self.next_track: -# log.debug("musicmuster.play_next(): no next track selected", True) -# return -# -# with Session() as session: -# # If there's currently a track playing, fade it. -# self.stop_playing(fade=True) -# -# # Move next track to current track. -# self.current_track = self.next_track -# self.next_track = None -# -# # If current track on different playlist_tab to last, reset -# # last track playlist_tab colour -# # Set current track playlist_tab colour -# if self.current_track_playlist_tab != self.next_track_playlist_tab: -# self.set_tab_colour(self.current_track_playlist_tab, -# QColor(Config.COLOUR_NORMAL_TAB)) -# -# # Update record of current track playlist_tab -# self.current_track_playlist_tab = self.next_track_playlist_tab -# self.next_track_playlist_tab = None -# -# # Set current track playlist_tab colour -# self.set_tab_colour(self.current_track_playlist_tab, -# QColor(Config.COLOUR_CURRENT_TAB)) -# -# # Restore volume if -3dB active -# if self.btnDrop3db.isChecked(): -# self.btnDrop3db.setChecked(False) -# -# # Play (new) current track -# start_at = datetime.now() -# self.music.play(self.current_track.path) -# -# # Tell database to record it as played -# Playdates(session, self.current_track.id) -# -# # Set last_played date -# Tracks.update_lastplayed(session, self.current_track.id) -# -# # Tell playlist track is now playing -# self.current_track_playlist_tab.play_started(session) -# -# # Disable play next controls -# self.disable_play_next_controls() -# -# # Update headers -# self.update_headers() -# -# # Update clocks -# self.label_track_length.setText( -# helpers.ms_to_mmss(self.current_track.duration) -# ) -# fade_at = self.current_track.fade_at -# silence_at = self.current_track.silence_at -# length = self.current_track.duration -# self.label_fade_length.setText( -# helpers.ms_to_mmss(silence_at - fade_at)) -# self.label_start_time.setText( -# start_at.strftime(Config.TRACK_TIME_FORMAT)) -# end_at = start_at + timedelta( -# milliseconds=self.current_track.duration) -# self.label_end_time.setText( -# end_at.strftime(Config.TRACK_TIME_FORMAT)) -# + + def play_next(self) -> None: + """ + Play next track. + + Actions required: + - If there is no next track set, return. + - If there's currently a track playing, fade it. + - Move next track to current track. + - Ensure playlist tabs are the correct colour + - Restore volume if -3dB active + - Play (new) current track. + - Tell database to record it as played + - Tell playlist track is now playing + - Note that track is now playing + - Disable play next controls + - Update headers + - Update clocks + """ + + # If there is no next track set, return. + if not self.next_track: + log.debug("musicmuster.play_next(): no next track selected") + return + + with Session() as session: + # If there's currently a track playing, fade it. + self.stop_playing(fade=True) + + # Move next track to current track. + self.current_track = self.next_track + self.next_track = None + + # Ensure playlist tabs are the correct colour + # If current track on different playlist_tab to last, reset + # last track playlist_tab colour + if self.current_track_playlist_tab != self.next_track_playlist_tab: + self.set_tab_colour(self.current_track_playlist_tab, + QColor(Config.COLOUR_NORMAL_TAB)) + # # Update record of current track playlist_tab + self.current_track_playlist_tab = self.next_track_playlist_tab + self.next_track_playlist_tab = None + # Set current track playlist_tab colour + self.set_tab_colour(self.current_track_playlist_tab, + QColor(Config.COLOUR_CURRENT_TAB)) + + # Restore volume if -3dB active + if self.btnDrop3db.isChecked(): + self.btnDrop3db.setChecked(False) + + # Play (new) current track + start_at = datetime.now() + self.music.play(self.current_track.path) + + # Tell database to record it as played + Playdates(session, self.current_track.id) + + # Tell playlist track is now playing + self.current_track_playlist_tab.play_started(session) + + # Note that track is now playing + self.playing = True + + # Disable play next controls + self.disable_play_next_controls() + + # Update headers + self.update_headers() + + # Update clocks + self.label_track_length.setText( + helpers.ms_to_mmss(self.current_track.duration) + ) + fade_at = self.current_track.fade_at + silence_at = self.current_track.silence_at + length = self.current_track.duration + self.label_fade_length.setText( + helpers.ms_to_mmss(silence_at - fade_at)) + self.label_start_time.setText( + start_at.strftime(Config.TRACK_TIME_FORMAT)) + end_at = start_at + timedelta( + milliseconds=self.current_track.duration) + self.label_end_time.setText( + end_at.strftime(Config.TRACK_TIME_FORMAT)) + # def search_database(self) -> None: # """Show dialog box to select and cue track from database""" # @@ -709,12 +689,12 @@ class Window(QMainWindow, Ui_MainWindow): # self.visible_playlist_tab().select_unplayed_tracks() def set_tab_colour(self, widget: PlaylistTab, colour: QColor) -> None: - """ - Find the tab containing the widget and set the text colour - """ + """ + Find the tab containing the widget and set the text colour + """ - idx = self.tabPlaylist.indexOf(widget) - self.tabPlaylist.tabBar().setTabTextColor(idx, colour) + idx = self.tabPlaylist.indexOf(widget) + self.tabPlaylist.tabBar().setTabTextColor(idx, colour) # # def song_info_search(self) -> None: # """ @@ -736,54 +716,48 @@ class Window(QMainWindow, Ui_MainWindow): # txt = urllib.parse.quote_plus(title) # url = Config.TAB_URL % txt # webbrowser.open(url, new=2) -# -# def stop(self) -> None: -# """Stop playing immediately""" -# -# log.debug("musicmuster.stop()") -# -# self.stop_playing(fade=False) -# -# def stop_playing(self, fade=True) -> None: -# """ -# Stop playing current track -# -# Actions required: -# - Return if not playing -# - Stop/fade track -# - Reset playlist_tab colour -# - Run end-of-track actions -# """ -# -# log.debug(f"musicmuster.stop_playing({fade=})", True) -# -# # Return if not playing -# if not self.playing: -# log.debug("musicmuster.stop_playing(): not playing", True) -# return -# -# # Stop/fade track -# self.previous_track_position = self.music.get_position() -# if fade: -# log.debug("musicmuster.stop_playing(): fading music", True) -# self.music.fade() -# else: -# log.debug("musicmuster.stop_playing(): stopping music", True) -# self.music.stop() -# -# # Reset playlist_tab colour -# if self.current_track_playlist_tab == self.next_track_playlist_tab: -# self.set_tab_colour(self.current_track_playlist_tab, -# QColor(Config.COLOUR_NEXT_TAB)) -# else: -# self.set_tab_colour(self.current_track_playlist_tab, -# QColor(Config.COLOUR_NORMAL_TAB)) -# -# # Run end-of-track actions -# self.end_of_track_actions() - def this_is_the_next_track(self, playlist_tab: PlaylistTab, - track: Tracks, session) -> None: + def stop(self) -> None: + """Stop playing immediately""" + + self.stop_playing(fade=False) + + def stop_playing(self, fade=True) -> None: + """ + Stop playing current track + + Actions required: + - Return if not playing + - Stop/fade track + - Reset playlist_tab colour + - Run end-of-track actions + """ + + # Return if not playing + if not self.playing: + return + + # Stop/fade track + self.previous_track_position = self.music.get_position() + if fade: + self.music.fade() + else: + self.music.stop() + + # Reset playlist_tab colour + if self.current_track_playlist_tab == self.next_track_playlist_tab: + self.set_tab_colour(self.current_track_playlist_tab, + QColor(Config.COLOUR_NEXT_TAB)) + else: + self.set_tab_colour(self.current_track_playlist_tab, + QColor(Config.COLOUR_NORMAL_TAB)) + + # Run end-of-track actions + self.end_of_track_actions() + + def this_is_the_next_track(self, session: Session, + playlist_tab: PlaylistTab, + track: Tracks) -> None: """ This is notification from a playlist tab that it holds the next track to be played. @@ -832,85 +806,81 @@ class Window(QMainWindow, Ui_MainWindow): # Populate 'info' tabs self.tabInfolist.open_tab(track.title) -# def tick(self) -> None: -# """ -# Carry out clock tick actions. -# -# 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. -# -# Actions required: -# - Update TOD clock -# - If track is playing, update track clocks time and colours -# - Else: run stop_track -# """ -# -# # Update TOD clock -# self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT)) -# -# self.even_tick = not self.even_tick -# if not self.even_tick: -# return -# -# # If track is playing, update track clocks time and colours -# if self.music.player and self.music.playing(): -# self.playing = True -# playtime: int = self.music.get_playtime() -# time_to_fade: int = (self.current_track.fade_at - playtime) -# time_to_silence: int = ( -# self.current_track.silence_at - playtime) -# time_to_end: int = (self.current_track.duration - playtime) -# -# # Elapsed time -# if time_to_end < 500: -# self.label_elapsed_timer.setText( -# helpers.ms_to_mmss(playtime) -# ) -# 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)) -# -# # If silent in the next 5 seconds, put warning colour on -# # time to silence box and enable play controls -# if time_to_silence <= 5500: -# self.frame_silent.setStyleSheet( -# f"background: {Config.COLOUR_ENDING_TIMER}" -# ) -# self.enable_play_next_controls() -# # Set warning colour on time to silence box when fade starts -# elif time_to_fade <= 500: -# self.frame_silent.setStyleSheet( -# f"background: {Config.COLOUR_WARNING_TIMER}" -# ) -# # Five seconds before fade starts, set warning colour on -# # time to silence box and enable play controls -# elif time_to_fade <= 5500: -# self.frame_fade.setStyleSheet( -# f"background: {Config.COLOUR_WARNING_TIMER}" -# ) -# self.enable_play_next_controls() -# else: -# self.frame_silent.setStyleSheet("") -# self.frame_fade.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.stop_playing() + def tick(self) -> None: + """ + Carry out clock tick actions. + + The Time of Day clock is updated every tick (500ms). + + All other timers are updated every second. As the timer displays + have a one-second resolution, updating every 500ms can result in + some timers updating and then, 500ms later, other timers + updating. That looks odd. + + Actions required: + - Update TOD clock + - If track is playing: + update track clocks time and colours + - Else: + run stop_track + """ + + # Update TOD clock + self.lblTOD.setText(datetime.now().strftime(Config.TOD_TIME_FORMAT)) + + self.even_tick = not self.even_tick + if not self.even_tick: + return + if not self.playing: + return + + # If track is playing, update track clocks time and colours + if self.music.player and self.music.player.is_playing(): + 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 + 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)) + + # If silent in the next 5 seconds, put warning colour on + # time to silence box and enable play controls + if time_to_silence <= 5500: + self.frame_silent.setStyleSheet( + f"background: {Config.COLOUR_ENDING_TIMER}" + ) + self.enable_play_next_controls() + # Set warning colour on time to silence box when fade starts + elif time_to_fade <= 500: + self.frame_silent.setStyleSheet( + f"background: {Config.COLOUR_WARNING_TIMER}" + ) + # Five seconds before fade starts, set warning colour on + # time to silence box and enable play controls + elif time_to_fade <= 5500: + self.frame_fade.setStyleSheet( + f"background: {Config.COLOUR_WARNING_TIMER}" + ) + self.enable_play_next_controls() + else: + self.frame_silent.setStyleSheet("") + self.frame_fade.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.stop_playing() def update_headers(self) -> None: """ @@ -919,23 +889,21 @@ class Window(QMainWindow, Ui_MainWindow): try: self.hdrPreviousTrack.setText( - f"{self.previous_track.title} - {self.previous_track.artist}" - ) - except (AttributeError, DetachedInstanceError): + f"{self.previous_track.title} - {self.previous_track.artist}") + except AttributeError: self.hdrPreviousTrack.setText("") try: self.hdrCurrentTrack.setText( - f"{self.current_track.title} - {self.current_track.artist}" - ) - except (AttributeError, DetachedInstanceError): + f"{self.current_track.title} - {self.current_track.artist}") + except AttributeError: self.hdrCurrentTrack.setText("") try: self.hdrNextTrack.setText( f"{self.next_track.title} - {self.next_track.artist}" ) - except (AttributeError, DetachedInstanceError): + except AttributeError: self.hdrNextTrack.setText("") # # diff --git a/app/playlists.py b/app/playlists.py index 1427d89..ad6ac62 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -1,15 +1,18 @@ -from collections import namedtuple +import re +import subprocess +import threading +from collections import namedtuple +from datetime import datetime, timedelta from typing import List, Optional -from PyQt5 import QtCore -from PyQt5.QtCore import Qt + +from PyQt5.QtCore import QEvent, Qt, pyqtSignal from PyQt5.QtGui import ( QBrush, QColor, QFont, QDropEvent ) -from PyQt5 import QtWidgets from PyQt5.QtWidgets import ( QAbstractItemView, # QApplication, @@ -22,17 +25,13 @@ from PyQt5.QtWidgets import ( QTableWidget, QTableWidgetItem, ) -# -import helpers -# import os -import re -import subprocess -import threading -# + from config import Config -from datetime import datetime # , timedelta +from dbconfig import Session from helpers import ( + file_is_readable, get_relative_date, + ms_to_mmss, open_in_audacity ) from log import log @@ -44,7 +43,7 @@ from models import ( Tracks, NoteColours ) -from dbconfig import Session + start_time_re = re.compile(r"@\d\d:\d\d:\d\d") @@ -54,7 +53,6 @@ class RowMeta: UNREADABLE = 2 NEXT = 3 CURRENT = 4 - PLAYED = 5 # Columns @@ -100,21 +98,21 @@ class PlaylistTab(QTableWidget): def __init__(self, musicmuster: QMainWindow, session: Session, playlist_id: int, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.musicmuster: QMainWindow = musicmuster - self.playlist_id: int = playlist_id + self.musicmuster = musicmuster + self.playlist_id = playlist_id self.menu: Optional[QMenu] = None -# self.current_track_start_time: Optional[datetime] = None + self.current_track_start_time: Optional[datetime] = None # # # Don't select text on edit # self.setItemDelegate(NoSelectDelegate(self)) # # Set up widget -# self.setEditTriggers(QtWidgets.QAbstractItemView.AllEditTriggers) +# self.setEditTriggers(QAbstractItemView.AllEditTriggers) self.setAlternatingRowColors(True) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.setRowCount(0) self.setColumnCount(len(columns)) @@ -138,7 +136,7 @@ class PlaylistTab(QTableWidget): self.setDragEnabled(False) # This property defines how the widget shows a context menu - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setContextMenuPolicy(Qt.CustomContextMenu) # This signal is emitted when the widget's contextMenuPolicy is # Qt::CustomContextMenu, and the user has requested a context # menu on the widget. @@ -247,8 +245,8 @@ class PlaylistTab(QTableWidget): def eventFilter(self, source, event): """Used to process context (right-click) menu, which is defined here""" - if (event.type() == QtCore.QEvent.MouseButtonPress and # noqa W504 - event.buttons() == QtCore.Qt.RightButton and # noqa W504 + if (event.type() == QEvent.MouseButtonPress and # noqa W504 + event.buttons() == Qt.RightButton and # noqa W504 source is self.viewport()): item = self.itemAt(event.pos()) if item is not None: @@ -395,14 +393,14 @@ class PlaylistTab(QTableWidget): # note: Notes = Notes( # session, self.playlist_id, row, dlg.textValue()) # self._insert_note(session, note, row, True) # checked -# -# def get_selected_row(self) -> Optional[int]: -# """Return row number of first selected row, or None if none selected""" -# -# if not self.selectionModel().hasSelection(): -# return None -# else: -# return self.selectionModel().selectedRows()[0].row() + + def get_selected_row(self) -> Optional[int]: + """Return row number of first selected row, or None if none selected""" + + if not self.selectionModel().hasSelection(): + return None + else: + return self.selectionModel().selectedRows()[0].row() # # def get_selected_rows(self) -> List[int]: # """Return a sorted list of selected row numbers""" @@ -415,7 +413,7 @@ class PlaylistTab(QTableWidget): # # if self.selectionModel().hasSelection(): # row = self.currentRow() -# return self.item(row, self.COL_TITLE).text() +# return self.item(row, FIXUP.COL_TITLE).text() # else: # return None @@ -459,7 +457,7 @@ class PlaylistTab(QTableWidget): self.setItem(row, columns['artist'].idx, artist_item) duration_item = QTableWidgetItem( - helpers.ms_to_mmss(row_data.track.duration)) + ms_to_mmss(row_data.track.duration)) self.setItem(row, columns['duration'].idx, duration_item) self._set_row_duration(row, row_data.track.duration) @@ -480,7 +478,7 @@ class PlaylistTab(QTableWidget): self.setItem(row, columns['lastplayed'].idx, last_played_item) # Mark track if file is unreadable - if not helpers.file_is_readable(row_data.track.path): + if not file_is_readable(row_data.track.path): self._set_unreadable_row(row) else: @@ -524,48 +522,48 @@ class PlaylistTab(QTableWidget): # item: QTableWidgetItem = QTableWidgetItem() # # Add row metadata # item.setData(self.ROW_FLAGS, 0) -# self.setItem(row, self.COL_USERDATA, item) +# self.setItem(row, FIXUP.COL_USERDATA, item) # # # Add track details to columns # mss_item: QTableWidgetItem = QTableWidgetItem(str(track.start_gap)) # if track.start_gap and track.start_gap >= 500: # mss_item.setBackground(QColor(Config.COLOUR_LONG_START)) -# self.setItem(row, self.COL_MSS, mss_item) +# self.setItem(row, FIXUP.COL_MSS, mss_item) # # title_item: QTableWidgetItem = QTableWidgetItem(track.title) -# self.setItem(row, self.COL_TITLE, title_item) +# self.setItem(row, FIXUP.COL_TITLE, title_item) # # artist_item: QTableWidgetItem = QTableWidgetItem(track.artist) -# self.setItem(row, self.COL_ARTIST, artist_item) +# self.setItem(row, FIXUP.COL_ARTIST, artist_item) # # duration_item: QTableWidgetItem = QTableWidgetItem( -# helpers.ms_to_mmss(track.duration) +# ms_to_mmss(track.duration) # ) # self._set_row_duration(row, track.duration) -# self.setItem(row, self.COL_DURATION, duration_item) +# self.setItem(row, FIXUP.COL_DURATION, duration_item) # # last_playtime: Optional[datetime] = Playdates.last_played( # session, track.id) # last_played_str: str = get_relative_date(last_playtime) # last_played_item: QTableWidgetItem = QTableWidgetItem(last_played_str) -# self.setItem(row, self.COL_LAST_PLAYED, last_played_item) +# self.setItem(row, FIXUP.COL_LAST_PLAYED, last_played_item) # # row_note: Optional[str] = "Play text" # row_note_item: QTableWidgetItem = QTableWidgetItem(row_note) -# self.setItem(row, self.COL_ROW_NOTES, row_note_item) +# self.setItem(row, FIXUP.COL_ROW_NOTES, row_note_item) # # # Add empty start and stop time because background # # colour won't be set for columns without items # start_item: QTableWidgetItem = QTableWidgetItem() -# self.setItem(row, self.COL_START_TIME, start_item) +# self.setItem(row, FIXUP.COL_START_TIME, start_item) # stop_item: QTableWidgetItem = QTableWidgetItem() -# self.setItem(row, self.COL_END_TIME, stop_item) +# self.setItem(row, FIXUP.COL_END_TIME, stop_item) # # # Attach track.id object to row # self._set_row_content(row, track.id) # # # Mark track if file is unreadable -# if not helpers.file_is_readable(track.path): +# if not file_is_readable(track.path): # self._set_unreadable_row(row) # # Scroll to new row # self.scrollToItem(title_item, QAbstractItemView.PositionAtCenter) @@ -618,62 +616,60 @@ class PlaylistTab(QTableWidget): # # self.save_playlist(session) # self.update_display(session) -# -# def play_started(self, session: Session) -> None: -# """ -# Notification from musicmuster that track has started playing. -# -# Actions required: -# - Note start time -# - Mark next-track row as current -# - Mark current row as played -# - Scroll to put current track as required -# - Set next track -# - Update display -# """ -# -# # Note start time -# self.current_track_start_time = datetime.now() -# -# # Mark next-track row as current -# current_row = self._get_next_track_row() -# if current_row is None: -# return -# self._set_current_track_row(current_row) -# -# # Mark current row as played -# self._set_played_row(current_row) -# -# # Scroll to put current track as requiredin middle We want this -# # row to be Config.SCROLL_TOP_MARGIN from the top. Rows number -# # from zero, so set (current_row - Config.SCROLL_TOP_MARGIN + 1) -# # row to be top row -# -# top_row = max(0, current_row - Config.SCROLL_TOP_MARGIN + 1) -# scroll_item = self.item(top_row, self.COL_MSS) -# self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop) -# -# # Set next track -# search_from = current_row + 1 -# next_row = self._find_next_track_row(search_from) -# if next_row: -# self._set_next(session, next_row) -# -# # Update display -# self.update_display(session) -# -# def play_stopped(self) -> None: -# """ -# Notification from musicmuster that track has ended. -# -# Actions required: -# - Remove current track marker -# - Reset current track start time -# - Update display -# """ -# -# self._clear_current_track_row() -# self.current_track_start_time = None + + def play_started(self, session: Session) -> None: + """ + Notification from musicmuster that track has started playing. + + Actions required: + - Note start time + - Mark next-track row as current + - Mark current row as played + - Scroll to put current track as required + - Set next track + - Update display + """ + + # Note start time + self.current_track_start_time = datetime.now() + + # Mark next-track row as current + current_row = self._get_next_track_row() + if current_row is None: + return + self._set_current_track_row(current_row) + + # Mark current row as played + self._set_played_row(session, current_row) + + # Scroll to put current track Config.SCROLL_TOP_MARGIN from the + # top. Rows number from zero, so set (current_row - + # Config.SCROLL_TOP_MARGIN + 1) row to be top row + + top_row = max(0, current_row - Config.SCROLL_TOP_MARGIN + 1) + scroll_item = self.item(top_row, 0) + self.scrollToItem(scroll_item, QAbstractItemView.PositionAtTop) + + # Set next track + search_from = current_row + 1 + next_row = self._find_next_track_row(session, search_from) + if next_row: + self._set_next(session, next_row) + + # Update display + self.update_display(session) + + def play_stopped(self) -> None: + """ + Notification from musicmuster that track has ended. + + Actions required: + - Remove current track marker + - Reset current track start time + """ + + self._clear_current_track_row() + self.current_track_start_time = None def populate(self, session: Session, playlist_id: int) -> None: """ @@ -687,12 +683,14 @@ class PlaylistTab(QTableWidget): # row: int # track: Tracks - playlist = session.get(Playlists, playlist_id) + # Sanity check row numbering before we load + PlaylistRows.fixup_rownumbers(session, playlist_id) # Clear playlist self.setRowCount(0) # Add the rows + playlist = session.get(Playlists, playlist_id) for row in playlist.rows: self.insert_row(session, row, repaint=False) @@ -802,7 +800,7 @@ class PlaylistTab(QTableWidget): # if row in notes_rows: # continue # track_id: int = self.item( -# row, self.COL_USERDATA).data(self.CONTENT_OBJECT) +# row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) # playlist.add_track(session, track_id, row) # session.commit() # @@ -903,16 +901,16 @@ class PlaylistTab(QTableWidget): # self.row_filter = text # with Session() as session: # self.update_display(session) -# -# def set_selected_as_next(self) -> None: -# """Sets the select track as next to play""" -# -# row = self.get_selected_row() -# if row is None: -# return None -# -# with Session() as session: -# self._set_next(session, row) + + def set_selected_as_next(self) -> None: + """Sets the select track as next to play""" + + row = self.get_selected_row() + if row is None: + return None + + with Session() as session: + self._set_next(session, row) def update_display(self, session, clear_selection: bool = True) -> None: """ @@ -932,7 +930,7 @@ class PlaylistTab(QTableWidget): current_row: Optional[int] = self._get_current_track_row() next_row: Optional[int] = self._get_next_track_row() - played: Optional[List[int]] = self._get_played_track_rows() + played = PlaylistRows.get_played_rows(session, self.playlist_id) unreadable: List[int] = self._get_unreadable_track_rows() if self.row_filter: @@ -969,7 +967,7 @@ class PlaylistTab(QTableWidget): if track: # Render unplayable tracks in correct colour - if not helpers.file_is_readable(track.path): + if not file_is_readable(track.path): self._set_row_colour(row, QColor(Config.COLOUR_UNREADABLE)) self._set_row_bold(row) continue @@ -1005,7 +1003,7 @@ class PlaylistTab(QTableWidget): self._set_row_start_time( row, self.current_track_start_time) # Set last played time to "Today" - self.item(row, self.COL_LAST_PLAYED).setText("Today") + self.item(row, columns['lastplayed'].idx).setText("Today") # Calculate next_start_time next_start_time = self._calculate_end_time( self.current_track_start_time, track.duration) @@ -1050,10 +1048,8 @@ class PlaylistTab(QTableWidget): if row in played: # Played today, so update last played column - last_playedtime = track.lastplayed - last_played_str = get_relative_date(last_playedtime) - self.item(row, self.COL_LAST_PLAYED).setText( - last_played_str) + self.item(row, columns['lastplayed'].idx).setText( + Config.LAST_PLAYED_TODAY_STRING) if self.musicmuster.hide_played_tracks: self.hideRow(row) else: @@ -1168,7 +1164,7 @@ class PlaylistTab(QTableWidget): # # if not self.editing_cell: # return -# if column not in [self.COL_TITLE, self.COL_ARTIST]: +# if column not in [FIXUP.COL_TITLE, FIXUP.COL_ARTIST]: # return # # new_text: str = self.item(row, column).text() @@ -1196,9 +1192,9 @@ class PlaylistTab(QTableWidget): # ) # else: # track: Tracks = self._get_row_track_object(row, session) -# if column == self.COL_ARTIST: +# if column == FIXUP.COL_ARTIST: # track.update_artist(session, artist=new_text) -# elif column == self.COL_TITLE: +# elif column == FIXUP.COL_TITLE: # track.update_title(session, title=new_text) # else: # log.error("_cell_changed(): unrecognised column") @@ -1236,29 +1232,25 @@ class PlaylistTab(QTableWidget): # # database. # # if self._is_note_row(row): -# item = self.item(row, self.COL_TITLE) +# item = self.item(row, FIXUP.COL_TITLE) # with Session() as session: # note_object = self._get_row_notes_object(row, session) # if note_object: # item.setText(note_object.note) # return -# -# def _clear_current_track_row(self) -> None: -# """ -# Clear current row if there is one. -# """ -# -# current_row: Optional[int] = self._get_current_track_row() -# if current_row is not None: -# self._meta_clear_attribute(current_row, RowMeta.CURRENT) -# # Reset row colour -# if current_row % 2: -# self._set_row_colour( -# current_row, QColor(Config.COLOUR_ODD_PLAYLIST)) -# else: -# self._set_row_colour( -# current_row, QColor(Config.COLOUR_EVEN_PLAYLIST)) -# + + def _clear_current_track_row(self) -> None: + """ + Clear current row if there is one. + """ + + current_row = self._get_current_track_row() + + if current_row is None: + return + + self._meta_clear_attribute(current_row, RowMeta.CURRENT) + # def _clear_played_row_status(self, row: int) -> None: # """Clear played status on row""" # @@ -1299,7 +1291,7 @@ class PlaylistTab(QTableWidget): # def _edit_note_cell(self, row, column): # review # """Called when table is single-clicked""" # -# if column in [self.COL_ROW_NOTES]: +# if column in [FIXUP.COL_ROW_NOTES]: # item = self.item(row, column) # self.editItem(item) # @@ -1310,7 +1302,7 @@ class PlaylistTab(QTableWidget): # column = mi.column() # item = self.item(row, column) # -# if column in [self.COL_TITLE, self.COL_ARTIST]: +# if column in [FIXUP.COL_TITLE, FIXUP.COL_ARTIST]: # self.editItem(item) # # def _get_notes_rows(self) -> List[int]: @@ -1335,32 +1327,30 @@ class PlaylistTab(QTableWidget): return track_id -# def _find_next_track_row(self, starting_row: int = None) -> Optional[int]: -# """ -# Find next track to play. If a starting row is given, start there; -# else if there's a track selected, start looking from next track; -# otherwise, start from top. Skip rows already played. -# -# If not found, return None. -# -# If found, return row number. -# """ -# -# if starting_row is None: -# current_row = self._get_current_track_row() -# if current_row is not None: -# starting_row = current_row + 1 -# else: -# starting_row = 0 -# notes_rows = self._get_notes_rows() -# played_rows = self._get_played_track_rows() -# for row in range(starting_row, self.rowCount()): -# if row in notes_rows or row in played_rows: -# continue -# else: -# return row -# -# return None + def _find_next_track_row(self, session: Session, + starting_row: int = None) -> Optional[int]: + """ + Find next track to play. If a starting row is given, start there; + otherwise, start from top. Skip rows already played. + + If not found, return None. + + If found, return row number. + """ + + if starting_row is None: + starting_row = 0 + + track_rows = PlaylistRows.get_rows_with_tracks(session, + self.playlist_id) + played_rows = PlaylistRows.get_played_rows(session, self.playlist_id) + for row in range(starting_row, self.rowCount()): + if row not in track_rows or row in played_rows: + continue + else: + return row + + return None def _get_current_track_row(self) -> Optional[int]: """Return row marked as current, or None""" @@ -1394,11 +1384,6 @@ class PlaylistTab(QTableWidget): except ValueError: return None - def _get_played_track_rows(self) -> List[int]: - """Return rows marked as played, or None""" - - return self._meta_search(RowMeta.PLAYED, one=False) - def _get_row_duration(self, row: int) -> int: """Return duration associated with this row""" @@ -1415,9 +1400,9 @@ class PlaylistTab(QTableWidget): # """ # # try: -# if self.item(row, self.COL_END_TIME): +# if self.item(row, FIXUP.COL_END_TIME): # return datetime.strptime(self.item( -# row, self.COL_END_TIME).text(), +# row, FIXUP.COL_END_TIME).text(), # Config.NOTE_TIME_FORMAT # ) # else: @@ -1429,7 +1414,7 @@ class PlaylistTab(QTableWidget): # -> Optional[Notes]: # """Return note associated with this row""" # -# note_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) +# note_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) # note = Notes.get_by_id(session, note_id) # return note # @@ -1457,7 +1442,7 @@ class PlaylistTab(QTableWidget): # -> Optional[Tracks]: # """Return track associated with this row""" # -# track_id = self.item(row, self.COL_USERDATA).data(self.CONTENT_OBJECT) +# track_id = self.item(row, FIXUP.COL_USERDATA).data(self.CONTENT_OBJECT) # track = Tracks.get_by_id(session, track_id) # return track # @@ -1481,9 +1466,9 @@ class PlaylistTab(QTableWidget): f"Title: {track.title}\n" f"Artist: {track.artist}\n" f"Track ID: {track.id}\n" - f"Track duration: {helpers.ms_to_mmss(track.duration)}\n" - f"Track fade at: {helpers.ms_to_mmss(track.fade_at)}\n" - f"Track silence at: {helpers.ms_to_mmss(track.silence_at)}" + f"Track duration: {ms_to_mmss(track.duration)}\n" + f"Track fade at: {ms_to_mmss(track.fade_at)}\n" + f"Track silence at: {ms_to_mmss(track.silence_at)}" "\n\n" f"Path: {track.path}\n" ) @@ -1515,14 +1500,14 @@ class PlaylistTab(QTableWidget): # # Add empty items to unused columns because # # colour won't be set for columns without items # item: QTableWidgetItem = QTableWidgetItem() -# self.setItem(row, self.COL_USERDATA, item) +# self.setItem(row, FIXUP.COL_USERDATA, item) # item = QTableWidgetItem() -# self.setItem(row, self.COL_MSS, item) +# self.setItem(row, FIXUP.COL_MSS, item) # # # Add text of note from title column onwards # titleitem: QTableWidgetItem = QTableWidgetItem(note.note) -# self.setItem(row, self.COL_NOTE, titleitem) -# self.setSpan(row, self.COL_NOTE, self.NOTE_ROW_SPAN, +# self.setItem(row, FIXUP.COL_NOTE, titleitem) +# self.setSpan(row, FIXUP.COL_NOTE, self.NOTE_ROW_SPAN, # self.NOTE_COL_SPAN) # # # Attach note id to row @@ -1711,28 +1696,25 @@ class PlaylistTab(QTableWidget): """Run args in subprocess""" subprocess.call(args) -# -# def _set_current_track_row(self, row: int) -> None: -# """Mark this row as current track""" -# -# self._clear_current_track_row() -# self._meta_set_attribute(row, RowMeta.CURRENT) + + def _set_current_track_row(self, row: int) -> None: + """Mark this row as current track""" + + self._clear_current_track_row() + self._meta_set_attribute(row, RowMeta.CURRENT) def _set_next_track_row(self, row: int) -> None: """Mark this row as next track""" self._meta_clear_next() self._meta_set_attribute(row, RowMeta.NEXT) -# -# def _set_note_row(self, row: int) -> None: -# """Mark this row as a note""" -# -# self._meta_set_attribute(row, RowMeta.NOTE) -# -# def _set_played_row(self, row: int) -> None: -# """Mark this row as played""" -# -# self._meta_set_attribute(row, RowMeta.PLAYED) + + def _set_played_row(self, session: Session, row: int) -> None: + """Mark this row as played""" + + plr = session.get(PlaylistRows, self._get_playlistrow_id(row)) + plr.played = True + session.commit() def _set_unreadable_row(self, row: int) -> None: """Mark this row as unreadable""" @@ -1762,7 +1744,7 @@ class PlaylistTab(QTableWidget): # Only paint message if there are selected track rows if ms > 0: self.musicmuster.lblSumPlaytime.setText( - f"Selected duration: {helpers.ms_to_mmss(ms)}") + f"Selected duration: {ms_to_mmss(ms)}") else: self.musicmuster.lblSumPlaytime.setText("") @@ -1781,7 +1763,7 @@ class PlaylistTab(QTableWidget): # """ # # # Need to allow multiple rows to be selected -# self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) +# self.setSelectionMode(QAbstractItemView.MultiSelection) # self.clear_selection() # # if played: @@ -1793,7 +1775,7 @@ class PlaylistTab(QTableWidget): # self.selectRow(row) # # # Reset extended selection -# self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) +# self.setSelectionMode(QAbstractItemView.ExtendedSelection) def _set_column_widths(self, session: Session) -> None: """Column widths from settings""" @@ -1832,7 +1814,7 @@ class PlaylistTab(QTableWidget): return # Check track is readable - if not helpers.file_is_readable(track.path): + if not file_is_readable(track.path): self._set_unreadable_row(row_number) return None @@ -1843,7 +1825,7 @@ class PlaylistTab(QTableWidget): self.update_display(session) # Notify musicmuster - self.musicmuster.this_is_the_next_track(self, track, session) + self.musicmuster.this_is_the_next_track(session, self, track) def _set_row_bold(self, row: int, bold: bool = True) -> None: """Make row bold (bold=True) or not bold""" @@ -1876,9 +1858,9 @@ class PlaylistTab(QTableWidget): # def _set_row_content(self, row: int, object_id: int) -> None: # """Set content associated with this row""" # -# assert self.item(row, self.COL_USERDATA) +# assert self.item(row, FIXUP.COL_USERDATA) # -# self.item(row, self.COL_USERDATA).setData( +# self.item(row, FIXUP.COL_USERDATA).setData( # self.CONTENT_OBJECT, object_id) def _set_row_duration(self, row: int, ms: int) -> None: @@ -1914,7 +1896,7 @@ class PlaylistTab(QTableWidget): # def _set_timed_section(self, session, start_row, ms, no_end=False): # """Add duration to a marked section""" # -# duration = helpers.ms_to_mmss(ms) +# duration = ms_to_mmss(ms) # note_object = self._get_row_notes_object(start_row, session) # if not note_object: # log.error("Can't get note_object in playlists._set_timed_section") @@ -1923,7 +1905,7 @@ class PlaylistTab(QTableWidget): # if no_end: # caveat = " (to end of playlist)" # display_text = note_text + ' [' + duration + caveat + ']' -# item = self.item(start_row, self.COL_TITLE) +# item = self.item(start_row, FIXUP.COL_TITLE) # item.setText(display_text) def _update_row(self, session, row: int, track: Tracks) -> None: @@ -1946,6 +1928,6 @@ class PlaylistTab(QTableWidget): item_artist.setText(track.artist) item_duration = self.item(row, columns['duration'].idx) - item_duration.setText(helpers.ms_to_mmss(track.duration)) + item_duration.setText(ms_to_mmss(track.duration)) self.update_display(session) diff --git a/app/ui_helpers.py b/app/ui_helpers.py index d842f1e..7e4520d 100644 --- a/app/ui_helpers.py +++ b/app/ui_helpers.py @@ -3,15 +3,15 @@ from PyQt5.QtGui import QFontMetrics, QPainter from PyQt5.QtWidgets import QLabel -class ElideLabel(QLabel): - """ - From https://stackoverflow.com/questions/11446478/ - pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize - """ - - def paintEvent(self, event): - painter = QPainter(self) - metrics = QFontMetrics(self.font()) - elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width()) - - painter.drawText(self.rect(), self.alignment(), elided) +# class ElideLabel(QLabel): +# """ +# From https://stackoverflow.com/questions/11446478/ +# pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize +# """ +# +# def paintEvent(self, event): +# painter = QPainter(self) +# metrics = QFontMetrics(self.font()) +# elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width()) +# +# painter.drawText(self.rect(), self.alignment(), elided) diff --git a/app/utilities.py b/app/utilities.py index 0377fda..7674862 100755 --- a/app/utilities.py +++ b/app/utilities.py @@ -264,7 +264,7 @@ # # Spike # # # # # Manage tracks listed in database but where path is invalid -# # log.debug(f"Invalid {path=} in database", True) +# # log.debug(f"Invalid {path=} in database") # # track = Tracks.get_by_path(session, path) # # messages.append(f"Remove from database: {path=} {track=}") # # @@ -279,10 +279,10 @@ # # for playlist_track in track.playlists: # # row = playlist_track.row # # # Remove playlist entry -# # log.debug(f"Remove {row=} from {playlist_track.playlist_id}", True) +# # log.debug(f"Remove {row=} from {playlist_track.playlist_id}") # # playlist_track.playlist.remove_track(session, row) # # # Create note -# # log.debug(f"Add note at {row=} to {playlist_track.playlist_id=}", True) +# # log.debug(f"Add note at {row=} to {playlist_track.playlist_id=}") # # Notes(session, playlist_track.playlist_id, row, note_txt) # # # # # Remove Track entry pointing to invalid path diff --git a/migrations/versions/0c604bf490f8_add_played_column_to_playlist_rows.py b/migrations/versions/0c604bf490f8_add_played_column_to_playlist_rows.py new file mode 100644 index 0000000..72b9a6c --- /dev/null +++ b/migrations/versions/0c604bf490f8_add_played_column_to_playlist_rows.py @@ -0,0 +1,32 @@ +"""Add 'played' column to playlist_rows + +Revision ID: 0c604bf490f8 +Revises: 29c0d7ffc741 +Create Date: 2022-08-12 14:12:38.419845 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '0c604bf490f8' +down_revision = '29c0d7ffc741' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('playlist_rows', sa.Column('played', sa.Boolean(), nullable=False)) + op.drop_index('ix_tracks_lastplayed', table_name='tracks') + op.drop_column('tracks', 'lastplayed') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tracks', sa.Column('lastplayed', mysql.DATETIME(), nullable=True)) + op.create_index('ix_tracks_lastplayed', 'tracks', ['lastplayed'], unique=False) + op.drop_column('playlist_rows', 'played') + # ### end Alembic commands ###