diff --git a/app/classes.py b/app/classes.py index 13fd84f..c98e92a 100644 --- a/app/classes.py +++ b/app/classes.py @@ -48,6 +48,7 @@ class MusicMusterSignals(QObject): show_warning_signal = pyqtSignal(str, str) span_cells_signal = pyqtSignal(int, int, int, int, int) status_message_signal = pyqtSignal(str, int) + track_ended_signal = pyqtSignal() def __post_init__(self): super().__init__() diff --git a/app/musicmuster.py b/app/musicmuster.py index f769ba0..81ca739 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -47,7 +47,6 @@ from PyQt6.QtWidgets import ( ) # Third party imports -# from pygame import mixer import pipeclient from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.session import Session @@ -55,11 +54,7 @@ import stackprinter # type: ignore # App imports from classes import ( - track_sequence, - FadeCurve, MusicMusterSignals, - PlaylistTrack, - PreviewTrackPlayer, TrackFileData, ) from config import Config @@ -68,6 +63,11 @@ from log import log from models import db, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks from playlistmodel import PlaylistModel, PlaylistProxyModel from playlists import PlaylistTab +from trackmanager import ( + MainTrackManager, + PreviewTrackManager, + track_sequence, +) from ui import icons_rc # noqa F401 from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore @@ -75,7 +75,6 @@ from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.main_window_ui import Ui_MainWindow # type: ignore from utilities import check_db, update_bitrates import helpers -import music class CartButton(QPushButton): @@ -231,10 +230,7 @@ class Window(QMainWindow, Ui_MainWindow): self.timer500: QTimer = QTimer() self.timer1000: QTimer = QTimer() - self.music: music.Music = music.Music(name=Config.VLC_MAIN_PLAYER_NAME) - self.preview_track_player: Optional[PreviewTrackPlayer] = None - - self.playing: bool = False + self.preview_track_player: Optional[PreviewTrackManager] = None self.set_main_window_size() self.lblSumPlaytime = QLabel("") @@ -420,6 +416,7 @@ class Window(QMainWindow, Ui_MainWindow): btn.setEnabled(True) # Setting to position 0 doesn't seem to work btn.player = self.music.VLC.media_player_new(btn.path) + MainTrackManager, btn.player.audio_set_volume(Config.VLC_VOLUME_DEFAULT) colour = Config.COLOUR_CART_READY btn.setStyleSheet("background-color: " + colour + ";\n") @@ -449,7 +446,7 @@ class Window(QMainWindow, Ui_MainWindow): return # Don't allow window to close when a track is playing - if self.playing: + if track_sequence.current and track_sequence.current.is_playing(): event.ignore() helpers.show_warning( self, "Track playing", "Can't close application while track is playing" @@ -604,6 +601,7 @@ class Window(QMainWindow, Ui_MainWindow): self.signals.next_track_changed_signal.connect(self.update_headers) self.signals.status_message_signal.connect(self.show_status_message) self.signals.show_warning_signal.connect(self.show_warning) + self.signals.track_ended_signal.connect(self.end_of_track_actions) self.timer10.timeout.connect(self.tick_10ms) self.timer500.timeout.connect(self.tick_500ms) @@ -724,10 +722,8 @@ class Window(QMainWindow, Ui_MainWindow): def drop3db(self) -> None: """Drop music level by 3db if button checked""" - if self.btnDrop3db.isChecked(): - self.music.set_volume(Config.VLC_VOLUME_DROP3db, set_default=False) - else: - self.music.set_volume(Config.VLC_VOLUME_DEFAULT, set_default=False) + if track_sequence.current: + track_sequence.current.drop3db(self.btnDrop3db.isChecked()) def enable_escape(self, enabled: bool) -> None: """ @@ -741,6 +737,38 @@ class Window(QMainWindow, Ui_MainWindow): self.action_Clear_selection.setEnabled(enabled) + def end_of_track_actions(self) -> None: + """ + + Actions required: + - Reset track_sequence objects + - Tell model track has finished + - Reset clocks + - Update headers + - Enable controls + """ + + # Reset track_sequence objects + track_sequence.previous = track_sequence.current + track_sequence.current = None + + # Tell model previous track has finished + self.active_proxy_model().previous_track_ended() + + # Reset clocks + self.frame_fade.setStyleSheet("") + self.frame_silent.setStyleSheet("") + self.label_elapsed_timer.setText("00:00 / 00:00") + self.label_fade_timer.setText("00:00") + self.label_silent_timer.setText("00:00") + + # Update headers + self.update_headers() + + # Enable controls + self.catch_return_key = False + self.show_status_message("Play controls: Enabled", 0) + def export_playlist_tab(self) -> None: """Export the current playlist to an m3u file""" @@ -788,7 +816,8 @@ class Window(QMainWindow, Ui_MainWindow): def fade(self) -> None: """Fade currently playing track""" - self.stop_playing(fade=True) + if track_sequence.current: + track_sequence.current.fade() def hide_played(self): """Toggle hide played tracks""" @@ -1152,8 +1181,8 @@ class Window(QMainWindow, Ui_MainWindow): # Suppress inadvertent double press if ( track_sequence.current - and track_sequence.current.track_player.start_time - and track_sequence.current.track_player.start_time + and track_sequence.current.start_time + and track_sequence.current.start_time + dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS) > dt.datetime.now() ): @@ -1161,16 +1190,10 @@ class Window(QMainWindow, Ui_MainWindow): # If return is pressed during first PLAY_NEXT_GUARD_MS then # default to NOT playing the next track, else default to # playing it. - default_yes: bool = ( - track_sequence.current.track_player.start_time is not None - and ( - ( - dt.datetime.now() - - track_sequence.current.track_player.start_time - ).total_seconds() - * 1000 - > Config.PLAY_NEXT_GUARD_MS - ) + default_yes: bool = track_sequence.current.start_time is not None and ( + (dt.datetime.now() - track_sequence.current.start_time).total_seconds() + * 1000 + > Config.PLAY_NEXT_GUARD_MS ) if not helpers.ask_yes_no( "Track playing", @@ -1194,14 +1217,15 @@ class Window(QMainWindow, Ui_MainWindow): # seconds of playback. Re-enabled tick_1000ms self.timer10.stop() - self.show_status_message("10ms timer disabled", 0) + log.debug("10ms timer disabled", 0) # If there's currently a track playing, fade it. - self.stop_playing(fade=True) + if track_sequence.current: + track_sequence.current.fade() # Move next track to current track. - # stop_playing() above has called end_of_track_actions() - # which will have populated self.previous_track + # end_of_track_actions() will have saved current track to + # previous_track track_sequence.current = track_sequence.next # Clear next track @@ -1213,7 +1237,7 @@ class Window(QMainWindow, Ui_MainWindow): self.btnDrop3db.setChecked(False) # Play (new) current track - track_sequence.current.track_player.play(position) + track_sequence.current.play(position) # Disable play next controls self.catch_return_key = True @@ -1251,7 +1275,7 @@ class Window(QMainWindow, Ui_MainWindow): self.btnPreview.setChecked(False) return with db.Session() as session: - self.preview_track_player = PreviewTrackPlayer(session, track_id) + self.preview_track_player = PreviewTrackManager(session, track_id) self.preview_track_player.play() else: @@ -1586,7 +1610,7 @@ class Window(QMainWindow, Ui_MainWindow): self.statusbar.showMessage(message, timing) - def show_track(self, playlist_track: PlaylistTrack) -> None: + def show_track(self, playlist_track: MainTrackManager) -> None: """Scroll to show track in plt""" # Switch to the correct tab @@ -1604,7 +1628,9 @@ class Window(QMainWindow, Ui_MainWindow): display_row = ( self.active_proxy_model() .mapFromSource( - self.active_proxy_model().source_model.index(playlist_track.row_number, 0) + self.active_proxy_model().source_model.index( + playlist_track.row_number, 0 + ) ) .row() ) @@ -1640,66 +1666,8 @@ class Window(QMainWindow, Ui_MainWindow): def stop(self) -> None: """Stop playing immediately""" - self.stop_playing(fade=False) - - def stop_playing(self, fade: bool = True) -> None: - """ - Stop playing current track - - Actions required: - - Set flag to say we're not playing a track - - Return if not playing - - Stop/fade track - - Reset playlist_tab colour - - Tell playlist_tab track has finished - - Reset PlaylistTrack objects - - Reset clocks - - Reset fade graph - - Update headers - - Enable controls - """ - - # Set flag to say we're not playing a track so that timer ticks - # don't see player=None and kick off end-of-track actions - if self.playing: - self.playing = False - else: - # Return if not playing - log.info("stop_playing() called but not playing") - return - - # Stop/fade track - track_sequence.now.resume_marker = self.music.get_position() - if fade: - self.music.fade() - else: - self.music.stop() - - # Reset fade graph - if track_sequence.now.fade_graph: - track_sequence.now.fade_graph.clear() - - # Reset track_sequence objects - if track_sequence.now.track_id: - track_sequence.previous = track_sequence.now - track_sequence.now = PlaylistTrack() - - # Tell model previous track has finished - self.active_proxy_model().previous_track_ended() - - # Reset clocks - self.frame_fade.setStyleSheet("") - self.frame_silent.setStyleSheet("") - self.label_elapsed_timer.setText("00:00 / 00:00") - self.label_fade_timer.setText("00:00") - self.label_silent_timer.setText("00:00") - - # Update headers - self.update_headers() - - # Enable controls - self.catch_return_key = False - self.show_status_message("Play controls: Enabled", 0) + if track_sequence.current: + track_sequence.current.stop() def tab_change(self): """Called when active tab changed""" @@ -1743,11 +1711,15 @@ class Window(QMainWindow, Ui_MainWindow): Called every 100ms """ + if track_sequence.current: + track_sequence.current.check_for_end_of_track() + + return # Update intro counter if applicable and, if updated, return # because playing an intro takes precedence over timing a # preview. - if self.music.is_playing() and track_sequence.now.intro: - remaining_ms = track_sequence.now.intro - self.music.get_playtime() + if self.music.is_playing() and track_sequence.current.intro: + remaining_ms = track_sequence.current.intro - self.music.get_playtime() if remaining_ms > 0: self.label_intro_timer.setText(f"{remaining_ms / 1000:.1f}") if remaining_ms <= Config.INTRO_SECONDS_WARNING_MS: @@ -1784,28 +1756,34 @@ class Window(QMainWindow, Ui_MainWindow): # Only update play clocks once a second so that their updates # are synchronised (otherwise it looks odd) - if not self.playing: - return + self.update_clocks() + + def update_clocks(self) -> None: + """ + Update track clocks. + """ # 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 = track_sequence.now.fade_at - playtime - time_to_silence = track_sequence.now.silence_at - playtime - - # see play_next() and issue #223 - if playtime > 10000 and not self.timer10.isActive(): + if track_sequence.current and track_sequence.current.is_playing(): + # see play_next() and issue #223. + # TODO: find a better way of handling this + if ( + track_sequence.current.time_playing() > 10000 + and not self.timer10.isActive() + ): self.timer10.start(10) - self.show_status_message("10ms timer enabled", 0) + log.debug("10ms timer enabled") # Elapsed time self.label_elapsed_timer.setText( - helpers.ms_to_mmss(playtime) + helpers.ms_to_mmss(track_sequence.current.time_playing()) + " / " - + helpers.ms_to_mmss(track_sequence.now.duration) + + helpers.ms_to_mmss(track_sequence.current.duration) ) # Time to fade + time_to_fade = track_sequence.current.time_to_fade() + time_to_silence = track_sequence.current.time_to_silence() self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade)) # If silent in the next 5 seconds, put warning colour on @@ -1816,11 +1794,13 @@ class Window(QMainWindow, Ui_MainWindow): self.frame_silent.setStyleSheet(css_silence) self.catch_return_key = False self.show_status_message("Play controls: Enabled", 0) + # Set warning colour on time to silence box when fade starts elif time_to_fade <= 500: css_fade = f"background: {Config.COLOUR_WARNING_TIMER}" if self.frame_silent.styleSheet() != css_fade: self.frame_silent.setStyleSheet(css_fade) + # Five seconds before fade starts, set warning colour on # time to silence box and enable play controls elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE: @@ -1835,38 +1815,30 @@ class Window(QMainWindow, Ui_MainWindow): self.label_silent_timer.setText(helpers.ms_to_mmss(time_to_silence)) - # Autoplay next track - # if time_to_silence <= 1500: - # self.play_next() - else: - if self.playing: - self.stop_playing() - def update_headers(self) -> None: """ Update last / current / next track headers """ if track_sequence.previous: - player = track_sequence.previous.track_player - self.hdrPreviousTrack.setText(f"{player.title} - {player.artist}") + self.hdrPreviousTrack.setText( + f"{track_sequence.previous.title} - {track_sequence.previous.artist}" + ) else: self.hdrPreviousTrack.setText("") if track_sequence.current: - player = track_sequence.current.track_player self.hdrCurrentTrack.setText( - f"{player.title.replace('&', '&&')} - " - f"{player.artist.replace('&', '&&')}" + f"{track_sequence.current.title.replace('&', '&&')} - " + f"{track_sequence.current.artist.replace('&', '&&')}" ) else: self.hdrCurrentTrack.setText("") if track_sequence.next: - player = track_sequence.next.track_player self.hdrNextTrack.setText( - f"{player.title.replace('&', '&&')} - " - f"{player.artist.replace('&', '&&')}" + f"{track_sequence.next.title.replace('&', '&&')} - " + f"{track_sequence.next.artist.replace('&', '&&')}" ) else: self.hdrNextTrack.setText("") diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 1e37608..d209ab1 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -30,7 +30,7 @@ import obswebsocket # type: ignore # import snoop # type: ignore # App imports -from classes import Col, track_sequence, MusicMusterSignals, PlaylistTrack +from classes import Col, MusicMusterSignals from config import Config from helpers import ( file_is_unreadable, @@ -41,6 +41,10 @@ from helpers import ( ) from log import log from models import db, NoteColours, Playdates, PlaylistRows, Tracks +from trackmanager import ( + MainTrackManager, + track_sequence, +) HEADER_NOTES_COLUMN = 1 @@ -684,7 +688,7 @@ class PlaylistModel(QAbstractTableModel): end_time_str = "" if ( track_sequence.current - and track_sequence.current.track_player.end_time + and track_sequence.current.end_time and ( row_number < track_sequence.current.row_number @@ -692,7 +696,7 @@ class PlaylistModel(QAbstractTableModel): ) ): section_end_time = ( - track_sequence.current.track_player.end_time + track_sequence.current.end_time + dt.timedelta(milliseconds=duration) ) end_time_str = ( @@ -1257,12 +1261,13 @@ class PlaylistModel(QAbstractTableModel): ) return - try: - track_sequence.next = PlaylistTrack(prd.plrid) - self.invalidate_row(row_number) - except ValueError as e: - log.error(f"Error creating PlaylistTrack({prd=}): ({str(e)})") - return + with db.Session() as session: + try: + track_sequence.next = MainTrackManager(session, prd.plrid) + self.invalidate_row(row_number) + except ValueError as e: + log.error(f"Error creating PlaylistTrack({prd=}): ({str(e)})") + return self.signals.search_wikipedia_signal.emit( self.playlist_rows[row_number].title @@ -1555,9 +1560,9 @@ class PlaylistProxyModel(QSortFilterProxyModel): and previous_plr.playlist_id == self.source_model.playlist_id ): - if track_sequence.current.track_player.start_time: + if track_sequence.current.start_time: if dt.datetime.now() > ( - track_sequence.current.track_player.start_time + track_sequence.current.start_time + dt.timedelta( milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET ) diff --git a/app/playlists.py b/app/playlists.py index 9c5dad8..222fce0 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -35,7 +35,7 @@ from PyQt6.QtWidgets import ( # Third party imports # App imports -from classes import Col, MusicMusterSignals, track_sequence +from classes import Col, MusicMusterSignals from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -47,6 +47,7 @@ from helpers import ( from log import log from models import db, Settings from playlistmodel import PlaylistModel, PlaylistProxyModel +from trackmanager import track_sequence if TYPE_CHECKING: from musicmuster import Window diff --git a/app/trackmanager.py b/app/trackmanager.py index a5dc534..bf53cbc 100644 --- a/app/trackmanager.py +++ b/app/trackmanager.py @@ -21,6 +21,7 @@ from PyQt6.QtCore import ( ) # App imports +from classes import MusicMusterSignals from config import Config from log import log from models import db, PlaylistRows, Tracks @@ -185,6 +186,25 @@ class _Music: else: self.start_dt -= dt.timedelta(milliseconds=ms) + def stop(self) -> None: + """Immediately stop playing""" + + log.debug(f"Music[{self.name}].stop()") + + self.start_dt = None + + if not self.player: + return + + p = self.player + self.player = None + self.start_dt = None + + with lock: + p.stop() + p.release() + p = None + def fade(self, fade_seconds: int) -> None: """ Fade the currently playing track. @@ -193,8 +213,6 @@ class _Music: to hold up the UI during the fade. """ - log.info(f"Music[{self.name}].stop()") - if not self.player: return @@ -202,7 +220,7 @@ class _Music: return if fade_seconds <= 0: - self._stop() + self.stop() return # Take a copy of current player to allow another track to be @@ -249,13 +267,10 @@ class _Music: # player.is_playing() returning True, so assume playing if less # than Config.PLAY_SETTLE microseconds have passed since # starting play. - return ( - self.start_dt is not None - and ( - self.player.is_playing() - or (dt.datetime.now() - self.start_dt) - < dt.timedelta(microseconds=Config.PLAY_SETTLE) - ) + return self.start_dt is not None and ( + self.player.is_playing() + or (dt.datetime.now() - self.start_dt) + < dt.timedelta(microseconds=Config.PLAY_SETTLE) ) def move_back(self, ms: int) -> None: @@ -342,25 +357,6 @@ class _Music: log.debug(f"Reset from {volume=}") sleep(0.1) - def _stop(self) -> None: - """Immediately stop playing""" - - log.info(f"Music[{self.name}].stop()") - - self.start_dt = None - - if not self.player: - return - - p = self.player - self.player = None - self.start_dt = None - - with lock: - p.stop() - p.release() - p = None - class _TrackManager: """ @@ -397,9 +393,10 @@ class _TrackManager: self.resume_marker: Optional[float] self.start_time: Optional[dt.datetime] = None - self.player = _Music(name=player_name) + self.signals = MusicMusterSignals() + # Initialise player - self.track_player = MainTrackManager(session=session, track_id=self.track_id) + self.player = _Music(name=player_name) # Initialise and add FadeCurve in a thread as it's slow self.fadecurve_thread = QThread() @@ -416,23 +413,50 @@ class _TrackManager: self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater) self.fadecurve_thread.start() + def check_for_end_of_track(self) -> None: + """ + Check whether track has ended. If so, emit track_ended_signal + """ + + if self.start_time is None: + return + + if not self.player.is_playing(): + self.start_time = None + self.signals.track_ended_signal.emit() + + def drop3db(self, enable: bool) -> None: + """ + If enable is true, drop output by 3db else restore to full volume + """ + + if enable: + self.player.set_volume(volume=Config.VLC_VOLUME_DROP3db, set_default=False) + else: + self.player.set_volume(volume=Config.VLC_VOLUME_DEFAULT, set_default=False) def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> None: """Fade music""" self.player.fade(fade_seconds) - @property def is_playing(self) -> bool: - return self.track_player.is_playing() + """ + Return True if we're currently playing else False + """ + + if self.start_time is None: + return False + + return self.player.is_playing() def play(self, position: Optional[float] = None) -> None: """Play track""" - now = dt.datetime.now() - self.start_time = now self.player.play(self.path, position) + now = dt.datetime.now() + self.start_time = now self.end_time = self.start_time + dt.timedelta(milliseconds=self.duration) # Calculate time fade_graph should start updating @@ -444,7 +468,7 @@ class _TrackManager: milliseconds=update_graph_at_ms ) - def stop_playing(self, fade_seconds: int = 0) -> None: + def stop(self, fade_seconds: int = 0) -> None: """ Stop this track playing """ @@ -456,41 +480,64 @@ class _TrackManager: if self.fade_graph: self.fade_graph.clear() + def time_playing(self) -> int: + """ + Return time track has been playing in milliseconds, zero if not playing + """ + + if self.start_time is None: + return 0 + + return self.player.get_playtime() + def time_to_fade(self) -> int: """ Return milliseconds until fade time. Return zero if we're not playing. """ - if not self.player.is_playing: + if self.start_time is None: return 0 + return self.fade_at - self.time_playing() + + def time_to_silence(self) -> int: + """ + Return milliseconds until silent. Return zero if we're not playing. + """ + + if self.start_time is None: + return 0 + + return self.silence_at - self.time_playing() + class MainTrackManager(_TrackManager): """ Manage playing tracks from the playlist with associated data """ - def __init__(self, plr_id: int) -> None: + def __init__(self, session: db.Session, plr_id: int) -> None: """ Set up manager for playlist tracks """ - with db.Session() as session: - # Ensure we have a track - plr = session.get(PlaylistRows, plr_id) - if not plr: - raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}") + # Ensure we have a track + plr = session.get(PlaylistRows, plr_id) + if not plr: + raise ValueError(f"PlaylistTrack: unable to retreive plr {plr_id=}") - self.track_id: int = plr.track_id + self.track_id: int = plr.track_id - super().__init__( - session=session, player_name=Config.VLC_MAIN_PLAYER_NAME, track_id=self.track_id - ) + super().__init__( + session=session, + player_name=Config.VLC_MAIN_PLAYER_NAME, + track_id=self.track_id, + ) - # Save non-track plr info - self.plr_id: int = plr.id - self.playlist_id: int = plr.playlist_id - self.row_number: int = plr.plr_rownum + # Save non-track plr info + self.plr_id: int = plr.id + self.playlist_id: int = plr.playlist_id + self.row_number: int = plr.plr_rownum def __repr__(self) -> str: return ( @@ -504,8 +551,9 @@ class PreviewTrackManager(_TrackManager): Manage previewing tracks """ - def __init__(self, track_id: int) -> None: + def __init__(self, session: db.Session, track_id: int) -> None: super().__init__( + session=session, player_name=Config.VLC_PREVIEW_PLAYER_NAME, track_id=track_id, )