diff --git a/app/classes.py b/app/classes.py index 1c30934..1bdf518 100644 --- a/app/classes.py +++ b/app/classes.py @@ -1,5 +1,6 @@ # Standard library imports from dataclasses import dataclass, field +from enum import auto, Enum from typing import Any, Optional import datetime as dt @@ -18,6 +19,19 @@ from models import PlaylistRows import helpers +class Col(Enum): + START_GAP = 0 + TITLE = auto() + ARTIST = auto() + INTRO = auto() + DURATION = auto() + START_TIME = auto() + END_TIME = auto() + LAST_PLAYED = auto() + BITRATE = auto() + NOTE = auto() + + class FadeCurve: GraphWidget = None diff --git a/app/config.py b/app/config.py index 68e96da..fcd2868 100644 --- a/app/config.py +++ b/app/config.py @@ -60,6 +60,7 @@ class Config(object): HEADER_TITLE = "Title" HIDE_AFTER_PLAYING_OFFSET = 5000 INFO_TAB_TITLE_LENGTH = 15 + INTRO_END_GAP_MS = 1000 INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_WARNING_MS = 3000 LAST_PLAYED_TODAY_STRING = "Today" diff --git a/app/music.py b/app/music.py index a0411a0..17a3619 100644 --- a/app/music.py +++ b/app/music.py @@ -77,7 +77,7 @@ class Music: if not position: position = 0 new_position = max(0, position + ((position * ms) / elapsed_ms)) - self.player.set_position(new_position) + self.set_position(new_position) # Adjus start time so elapsed time calculations are correct if new_position == 0: self.start_dt = dt.datetime.now() @@ -125,21 +125,6 @@ class Music: elapsed_seconds = (now - self.start_dt).total_seconds() return int(elapsed_seconds * 1000) - def get_playtime(self) -> int: - """ - Return number of milliseconds current track has been playing or - zero if not playing. The vlc function get_time() only updates 3-4 - times a second; this function has much better resolution. - """ - - if self.player is None or self.start_dt is None: - return 0 - - now = dt.datetime.now() - elapsed_time = now - self.start_dt - elapsed_seconds = elapsed_time.seconds + (elapsed_time.microseconds / 1000000) - return int(elapsed_seconds * 1000) - def get_position(self) -> Optional[float]: """Return current position""" @@ -220,6 +205,14 @@ class Music: log.error(f"Reset from {volume=}") sleep(0.1) + def set_position(self, position: int) -> None: + """ + Set player position + """ + + if self.player: + self.player.set_position(position) + def set_volume(self, volume=None, set_default=True) -> None: """Set maximum volume used for player""" diff --git a/app/musicmuster.py b/app/musicmuster.py index a0d18d7..dd94fbc 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -582,11 +582,13 @@ class Window(QMainWindow, Ui_MainWindow): self.btnDrop3db.clicked.connect(self.drop3db) self.btnFade.clicked.connect(self.fade) self.btnHidePlayed.clicked.connect(self.hide_played) + self.btnPreviewArm.clicked.connect(self.preview_arm) self.btnPreviewBack.clicked.connect(self.preview_back) self.btnPreview.clicked.connect(self.preview) - self.btnPreviewArm.clicked.connect(self.preview_arm) + self.btnPreviewEnd.clicked.connect(self.preview_end) self.btnPreviewFwd.clicked.connect(self.preview_fwd) self.btnPreviewMark.clicked.connect(self.preview_mark) + self.btnPreviewStart.clicked.connect(self.preview_start) self.btnStop.clicked.connect(self.stop) self.hdrCurrentTrack.clicked.connect(self.show_current) self.hdrNextTrack.clicked.connect(self.show_next) @@ -1259,6 +1261,7 @@ class Window(QMainWindow, Ui_MainWindow): else: self.preview_player.stop() self.label_intro_timer.setText("0.0") + self.btnPreviewMark.setEnabled(False) def preview_arm(self): """Manager arm button for setting intro length""" @@ -1270,6 +1273,23 @@ class Window(QMainWindow, Ui_MainWindow): self.preview_player.move_back(Config.PREVIEW_BACK_MS) + def preview_end(self) -> None: + """Advance preview file to just before end of intro""" + + return + + # preview_track_path = self.preview_player.path + # if not preview_track_path: + # return + + # with Session() as session: + # preview_track = Tracks.get_by_path(session, preview_track_path) + # if not preview_track or not preview_track.intro: + # return + + # new_position = max(0, preview_track.intro - Config.INTRO_END_GAP_MS) + # self.preview_player.set_position(new_position) + def preview_fwd(self) -> None: """Advance preview file""" @@ -1284,11 +1304,19 @@ class Window(QMainWindow, Ui_MainWindow): with db.Session() as session: track = session.get(Tracks, track_id) if track: - track.intro = self.preview_player.get_playtime() + # Save intro as millisends rounded to nearest 0.1 + # second because editor spinbox only resolves to 0.1 + # seconds + track.intro = round(self.preview_player.get_playtime() / 100) * 100 session.commit() self.active_tab().source_model.refresh_row(session, row_number) self.active_tab().source_model.invalidate_row(row_number) + def preview_start(self) -> None: + """Advance preview file""" + + self.preview_player.set_position(0) + def rename_playlist(self) -> None: """ Rename current playlist @@ -1734,7 +1762,14 @@ class Window(QMainWindow, Ui_MainWindow): if self.preview_player.is_playing(): playtime = self.preview_player.get_playtime() self.label_intro_timer.setText(f"{playtime / 1000:.1f}") - + if playtime <= 0: + self.label_intro_timer.setStyleSheet( + f"background: {Config.COLOUR_ENDING_TIMER}" + ) + elif playtime <= Config.INTRO_SECONDS_WARNING_MS: + self.label_intro_timer.setStyleSheet( + f"background: {Config.COLOUR_WARNING_TIMER}" + ) else: self.label_intro_timer.setText("0.0") diff --git a/app/playlistmodel.py b/app/playlistmodel.py index 6dc241f..14780fc 100644 --- a/app/playlistmodel.py +++ b/app/playlistmodel.py @@ -2,7 +2,6 @@ # Allow forward reference to PlaylistModel from __future__ import annotations -from enum import auto, Enum from operator import attrgetter from random import shuffle from typing import List, Optional @@ -31,7 +30,7 @@ import obswebsocket # type: ignore # import snoop # type: ignore # App imports -from classes import track_sequence, MusicMusterSignals, PlaylistTrack +from classes import Col, track_sequence, MusicMusterSignals, PlaylistTrack from config import Config from helpers import ( file_is_unreadable, @@ -48,19 +47,6 @@ HEADER_NOTES_COLUMN = 1 scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]") -class Col(Enum): - START_GAP = 0 - TITLE = auto() - ARTIST = auto() - INTRO = auto() - DURATION = auto() - START_TIME = auto() - END_TIME = auto() - LAST_PLAYED = auto() - BITRATE = auto() - NOTE = auto() - - class PlaylistRowData: def __init__(self, plr: PlaylistRows) -> None: """ @@ -490,6 +476,8 @@ class PlaylistModel(QAbstractTableModel): if self.is_header_row(row) and column == HEADER_NOTES_COLUMN: return QVariant(prd.note) + if column == Col.INTRO.value: + return QVariant(prd.intro) if column == Col.TITLE.value: return QVariant(prd.title) if column == Col.ARTIST.value: @@ -512,7 +500,12 @@ class PlaylistModel(QAbstractTableModel): | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled ) - if index.column() in [Col.TITLE.value, Col.ARTIST.value, Col.NOTE.value]: + if index.column() in [ + Col.TITLE.value, + Col.ARTIST.value, + Col.NOTE.value, + Col.INTRO.value, + ]: return default | Qt.ItemFlag.ItemIsEditable return default @@ -1299,7 +1292,7 @@ class PlaylistModel(QAbstractTableModel): self.update_track_times() def setData( - self, index: QModelIndex, value: QVariant, role: int = Qt.ItemDataRole.EditRole + self, index: QModelIndex, value: str | float, role: int = Qt.ItemDataRole.EditRole ) -> bool: """ Update model with edited data @@ -1319,7 +1312,7 @@ class PlaylistModel(QAbstractTableModel): return False if plr.track_id: - if column == Col.TITLE.value or column == Col.ARTIST.value: + if column in [Col.TITLE.value, Col.ARTIST.value, Col.INTRO.value]: track = session.get(Tracks, plr.track_id) if not track: print(f"Error retreiving track: {plr=}") @@ -1328,11 +1321,14 @@ class PlaylistModel(QAbstractTableModel): track.title = str(value) elif column == Col.ARTIST.value: track.artist = str(value) + elif column == Col.INTRO.value: + track.intro = int(round(float(value), 1) * 1000) else: print(f"Error updating track: {column=}, {value=}") return False elif column == Col.NOTE.value: plr.note = str(value) + else: # This is a header row if column == HEADER_NOTES_COLUMN: diff --git a/app/playlists.py b/app/playlists.py index 2a51028..dc10740 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -17,24 +17,25 @@ from PyQt6.QtWidgets import ( QAbstractItemDelegate, QAbstractItemView, QApplication, + QDoubleSpinBox, QHeaderView, QMenu, QMessageBox, QPlainTextEdit, + QProxyStyle, + QStyle, QStyledItemDelegate, + QStyleOption, QStyleOptionViewItem, QTableView, QTableWidgetItem, QWidget, - QProxyStyle, - QStyle, - QStyleOption, ) # Third party imports # App imports -from classes import MusicMusterSignals, track_sequence +from classes import Col, MusicMusterSignals, track_sequence from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -73,15 +74,22 @@ class EscapeDelegate(QStyledItemDelegate): Intercept createEditor call and make row just a little bit taller """ + self.editor: QDoubleSpinBox | QPlainTextEdit + self.signals = MusicMusterSignals() self.signals.enable_escape_signal.emit(False) if isinstance(self.parent(), PlaylistTab): p = cast(PlaylistTab, self.parent()) - if isinstance(index.data(), str): + if index.column() == Col.INTRO.value: + self.editor = QDoubleSpinBox(parent) + self.editor.setDecimals(1) + self.editor.setSingleStep(0.1) + return self.editor + elif isinstance(index.data(), str): + self.editor = QPlainTextEdit(parent) row = index.row() row_height = p.rowHeight(row) p.setRowHeight(row, row_height + Config.MINIMUM_ROW_HEIGHT) - self.editor = QPlainTextEdit(parent) return self.editor return super().createEditor(parent, option, index) @@ -107,10 +115,16 @@ class EscapeDelegate(QStyledItemDelegate): self.closeEditor.emit(editor) return True elif key_event.key() == Qt.Key.Key_Escape: - if self.original_text == self.editor.toPlainText(): - # No changes made + # Close editor if no changes have been made + data_modified = False + if isinstance(self.editor, QPlainTextEdit): + data_modified = self.original_model_data == self.editor.toPlainText() + elif isinstance(self.editor, QDoubleSpinBox): + data_modified = self.original_model_data == int(self.editor.value()) * 1000 + if data_modified: self.closeEditor.emit(editor) return True + discard_edits = QMessageBox.question( cast(QWidget, self.parent()), "Abandon edit", "Discard changes?" ) @@ -123,16 +137,22 @@ class EscapeDelegate(QStyledItemDelegate): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) - self.original_text = self.source_model.data( + self.original_model_data = self.source_model.data( edit_index, Qt.ItemDataRole.EditRole ) - editor.setPlainText(self.original_text.value()) + if index.column() == Col.INTRO.value: + editor.setValue(self.original_model_data.value() / 1000) + else: + editor.setPlainText(self.original_model_data.value()) def setModelData(self, editor, model, index): proxy_model = index.model() edit_index = proxy_model.mapToSource(index) - value = editor.toPlainText().strip() + if isinstance(self.editor, QPlainTextEdit): + value = editor.toPlainText().strip() + elif isinstance(self.editor, QDoubleSpinBox): + value = editor.value() self.source_model.setData(edit_index, value, Qt.ItemDataRole.EditRole) def updateEditorGeometry(self, editor, option, index):