Compare commits

..

No commits in common. "a46b9a3d6f27ec87d6ca87b5a71f386bb5115498" and "b1f682d2e6326448cd5983e69e41614e57c7bfa1" have entirely different histories.

12 changed files with 1249 additions and 1294 deletions

View File

@ -2,13 +2,20 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import auto, Enum from enum import auto, Enum
from typing import Any, Optional from typing import Any, Optional
import datetime as dt
# PyQt imports # PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject from PyQt6.QtCore import pyqtSignal, QObject, QThread
# Third party imports # Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm import scoped_session
# App imports # App imports
from config import Config
from log import log
from models import PlaylistRows
import helpers import helpers
@ -25,6 +32,62 @@ class Col(Enum):
NOTE = auto() NOTE = auto()
class FadeCurve:
GraphWidget = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = helpers.get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms = max(0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1)
self.end_ms = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
@helpers.singleton @helpers.singleton
@dataclass @dataclass
class MusicMusterSignals(QObject): class MusicMusterSignals(QObject):
@ -48,12 +111,117 @@ class MusicMusterSignals(QObject):
show_warning_signal = pyqtSignal(str, str) show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int) span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int) status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self): def __post_init__(self):
super().__init__() super().__init__()
class PlaylistTrack:
"""
Used to provide a single reference point for specific playlist tracks,
typically the previous, current and next track.
"""
def __init__(self) -> None:
"""
Only initialises data structure. Call set_plr to populate.
"""
self.artist: Optional[str] = None
self.duration: Optional[int] = None
self.end_time: Optional[dt.datetime] = None
self.fade_at: Optional[int] = None
self.fade_graph: Optional[FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.fade_length: Optional[int] = None
self.path: Optional[str] = None
self.playlist_id: Optional[int] = None
self.plr_id: Optional[int] = None
self.plr_rownum: Optional[int] = None
self.resume_marker: Optional[float] = None
self.silence_at: Optional[int] = None
self.start_gap: Optional[int] = None
self.start_time: Optional[dt.datetime] = None
self.title: Optional[str] = None
self.track_id: Optional[int] = None
def __repr__(self) -> str:
return (
f"<PlaylistTrack(title={self.title}, artist={self.artist}, "
f"plr_rownum={self.plr_rownum}, playlist_id={self.playlist_id}>"
)
def set_plr(self, session: scoped_session, plr: PlaylistRows) -> None:
"""
Update with new plr information
"""
session.add(plr)
self.plr_rownum = plr.plr_rownum
if not plr.track:
return
track = plr.track
self.artist = track.artist
self.duration = track.duration
self.end_time = None
self.fade_at = track.fade_at
self.intro = track.intro
self.path = track.path
self.playlist_id = plr.playlist_id
self.plr_id = plr.id
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.start_time = None
self.title = track.title
self.track_id = track.id
if track.silence_at and track.fade_at:
self.fade_length = track.silence_at - track.fade_at
# Initialise and add FadeCurve in a thread as it's slow
self.fadecurve_thread = QThread()
self.worker = AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.fadecurve_thread.finished.connect(self.fadecurve_thread.deleteLater)
self.fadecurve_thread.start()
def start(self) -> None:
"""
Called when track starts playing
"""
now = dt.datetime.now()
self.start_time = now
if self.duration:
self.end_time = self.start_time + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
# Calculate time fade_graph should start updating
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
@dataclass @dataclass
class TrackFileData: class TrackFileData:
""" """
@ -66,3 +234,46 @@ class TrackFileData:
obsolete_path: Optional[str] = None obsolete_path: Optional[str] = None
tags: dict[str, Any] = field(default_factory=dict) tags: dict[str, Any] = field(default_factory=dict)
audio_metadata: dict[str, str | int | float] = field(default_factory=dict) audio_metadata: dict[str, str | int | float] = field(default_factory=dict)
class AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
playlist_track: PlaylistTrack,
track_path: str,
track_fade_at: int,
track_silence_at: int,
):
super().__init__()
self.playlist_track = playlist_track
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self):
"""
Create fade curve and add to PlaylistTrack object
"""
fc = FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.playlist_track.fade_graph = fc
self.finished.emit()
class TrackSequence:
next = PlaylistTrack()
now = PlaylistTrack()
previous = PlaylistTrack()
track_sequence = TrackSequence()

View File

@ -9,9 +9,17 @@ class Config(object):
AUDIO_SEGMENT_CHUNK_SIZE = 10 AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192 BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300 BITRATE_OK_THRESHOLD = 300
CART_DIRECTORY = "/home/kae/radio/CartTracks"
CARTS_COUNT = 10
CARTS_HIDE = True
COLOUR_BITRATE_LOW = "#ffcdd2" COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f" COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8" COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CART_ERROR = "#dc3545"
COLOUR_CART_PLAYING = "#248f24"
COLOUR_CART_PROGRESSBAR = "#000000"
COLOUR_CART_READY = "#ffc107"
COLOUR_CART_UNCONFIGURED = "#f2f2f2"
COLOUR_CURRENT_PLAYLIST = "#7eca8f" COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24" COLOUR_CURRENT_TAB = "#248f24"
COLOUR_ENDING_TIMER = "#dc3545" COLOUR_ENDING_TIMER = "#dc3545"
@ -52,6 +60,7 @@ class Config(object):
HEADER_TITLE = "Title" HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000 HIDE_AFTER_PLAYING_OFFSET = 5000
INFO_TAB_TITLE_LENGTH = 15 INFO_TAB_TITLE_LENGTH = 15
INTRO_END_GAP_MS = 1000
INTRO_SECONDS_FORMAT = ".1f" INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000 INTRO_SECONDS_WARNING_MS = 3000
LAST_PLAYED_TODAY_STRING = "Today" LAST_PLAYED_TODAY_STRING = "Today"
@ -77,7 +86,6 @@ class Config(object):
PLAY_SETTLE = 500000 PLAY_SETTLE = 500000
PREVIEW_ADVANCE_MS = 5000 PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000 PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp" REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RETURN_KEY_DEBOUNCE_MS = 500 RETURN_KEY_DEBOUNCE_MS = 500
ROOT = os.environ.get("ROOT") or "/home/kae/music" ROOT = os.environ.get("ROOT") or "/home/kae/music"

View File

@ -23,6 +23,23 @@ from sqlalchemy.orm import (
# Database classes # Database classes
class CartsTable(Model):
__tablename__ = "carts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
cart_number: Mapped[int] = mapped_column(unique=True)
name: Mapped[str] = mapped_column(String(256), index=True)
duration: Mapped[Optional[int]] = mapped_column(index=True)
path: Mapped[Optional[str]] = mapped_column(String(2048), index=False)
enabled: Mapped[Optional[bool]] = mapped_column(default=False)
def __repr__(self) -> str:
return (
f"<Carts(id={self.id}, cart={self.cart_number}, "
f"name={self.name}, path={self.path}>"
)
class NoteColoursTable(Model): class NoteColoursTable(Model):
__tablename__ = "notecolours" __tablename__ = "notecolours"

View File

@ -37,6 +37,28 @@ db = Alchemical(ALCHEMICAL_DATABASE_URI, engine_options=Config.ENGINE_OPTIONS)
# Database classes # Database classes
class Carts(dbtables.CartsTable):
def __init__(
self,
session: Session,
cart_number: int,
name: str,
duration: Optional[int] = None,
path: Optional[str] = None,
enabled: bool = True,
) -> None:
"""Create new cart"""
self.cart_number = cart_number
self.name = name
self.duration = duration
self.path = path
self.enabled = enabled
session.add(self)
session.commit()
class NoteColours(dbtables.NoteColoursTable): class NoteColours(dbtables.NoteColoursTable):
def __init__( def __init__(
self, self,

260
app/music.py Normal file
View File

@ -0,0 +1,260 @@
# Standard library imports
import datetime as dt
import threading
from time import sleep
from typing import Optional
# Third party imports
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
QRunnable,
QThreadPool,
)
# App imports
from config import Config
from helpers import file_is_unreadable
from log import log
lock = threading.Lock()
class FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
log.debug(f"Releasing player {self.player=}")
self.player.release()
class Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name) -> None:
self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
self.player = None
self.name = name
self.max_volume = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
def _adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0
new_position = max(0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
else:
self.start_dt -= dt.timedelta(milliseconds=ms)
def fade(self, fade_seconds: int = Config.FADEOUT_SECONDS) -> 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.
"""
log.info(f"Music[{self.name}].stop()")
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
fader = FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
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.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# player.is_playing() returning True, so assume playing if less
# than Config.PLAY_SETTLE microseconds have passed since
# starting play.
return (
self.player is not None
and 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:
"""
Rewind player by ms milliseconds
"""
self._adjust_by_ms(ms * -1)
def move_forward(self, ms: int) -> None:
"""
Rewind player by ms milliseconds
"""
self._adjust_by_ms(ms)
def play(self, path: str, position: Optional[float] = None) -> None:
"""
Start playing the track at path.
Log and return if path not found.
"""
log.info(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = dt.datetime.now()
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
for _ in range(3):
if self.player:
volume = self.player.audio_get_volume()
if volume < Config.VLC_VOLUME_DEFAULT:
self.set_volume(Config.VLC_VOLUME_DEFAULT)
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"""
if not self.player:
return
if set_default:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
def stop(self) -> float:
"""Immediately stop playing"""
log.info(f"Music[{self.name}].stop()")
self.start_dt = None
if not self.player:
return 0.0
p = self.player
self.player = None
self.start_dt = None
with lock:
position = p.get_position()
p.stop()
p.release()
p = None
return position

View File

@ -2,19 +2,23 @@
# Standard library imports # Standard library imports
from os.path import basename from os.path import basename
from typing import List, Optional from time import sleep
from typing import cast, List, Optional
import argparse import argparse
import datetime as dt import datetime as dt
import os import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading
# PyQt imports # PyQt imports
from PyQt6.QtCore import ( from PyQt6.QtCore import (
pyqtSignal, pyqtSignal,
QDate, QDate,
QEvent,
QObject, QObject,
QSize,
Qt, Qt,
QThread, QThread,
QTime, QTime,
@ -23,7 +27,10 @@ from PyQt6.QtCore import (
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QCloseEvent, QCloseEvent,
QColor, QColor,
QFont,
QMouseEvent,
QPalette, QPalette,
QResizeEvent,
) )
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
@ -35,9 +42,12 @@ from PyQt6.QtWidgets import (
QListWidgetItem, QListWidgetItem,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QProgressBar,
QPushButton,
) )
# Third party imports # Third party imports
# from pygame import mixer
import pipeclient import pipeclient
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -45,26 +55,93 @@ import stackprinter # type: ignore
# App imports # App imports
from classes import ( from classes import (
track_sequence,
FadeCurve,
MusicMusterSignals, MusicMusterSignals,
PlaylistTrack,
TrackFileData, TrackFileData,
) )
from config import Config from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog from dialogs import TrackSelectDialog, ReplaceFilesDialog
from log import log from log import log
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks from models import db, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab from playlists import PlaylistTab
from trackmanager import (
MainTrackManager,
PreviewTrackManager,
track_sequence,
)
from ui import icons_rc # noqa F401 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 from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates from utilities import check_db, update_bitrates
import helpers import helpers
import music
class CartButton(QPushButton):
"""Button for playing carts"""
progress = pyqtSignal(int)
def __init__(self, musicmuster: "Window", cart: Carts, *args, **kwargs):
"""Create a cart pushbutton and set it disabled"""
super().__init__(*args, **kwargs)
self.musicmuster = musicmuster
self.cart_id = cart.id
if cart.path and cart.enabled and not cart.duration:
tags = helpers.get_tags(cart.path)
cart.duration = tags["duration"]
self.duration = cart.duration
self.path = cart.path
self.player = None
self.is_playing = False
self.setEnabled(True)
self.setMinimumSize(QSize(147, 61))
font = QFont()
font.setPointSize(14)
self.setFont(font)
self.setObjectName("cart_" + str(cart.cart_number))
self.pgb = QProgressBar(self)
self.pgb.setTextVisible(False)
self.pgb.setVisible(False)
palette = self.pgb.palette()
palette.setColor(
QPalette.ColorRole.Highlight, QColor(Config.COLOUR_CART_PROGRESSBAR)
)
self.pgb.setPalette(palette)
self.pgb.setGeometry(0, 0, self.width(), 10)
self.pgb.setMinimum(0)
self.pgb.setMaximum(1)
self.pgb.setValue(0)
self.progress.connect(self.pgb.setValue)
def __repr__(self) -> str:
return (
f"<CartButton(cart_id={self.cart_id} "
f"path={self.path}, is_playing={self.is_playing}>"
)
def event(self, event: Optional[QEvent]) -> bool:
"""Allow right click even when button is disabled"""
if not event:
return False
if event.type() == QEvent.Type.MouseButtonRelease:
mouse_event = cast(QMouseEvent, event)
if mouse_event.button() == Qt.MouseButton.RightButton:
self.musicmuster.cart_edit(self, event)
return True
return super().event(event)
def resizeEvent(self, event: Optional[QResizeEvent]) -> None:
"""Resize progess bar when button size changes"""
self.pgb.setGeometry(0, 0, self.width(), 10)
class ImportTrack(QObject): class ImportTrack(QObject):
@ -153,7 +230,11 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer500: QTimer = QTimer() self.timer500: QTimer = QTimer()
self.timer1000: QTimer = QTimer() self.timer1000: QTimer = QTimer()
self.preview_track_manager: Optional[PreviewTrackManager] = None self.music: music.Music = music.Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.preview_player: music.Music = music.Music(
name=Config.VLC_PREVIEW_PLAYER_NAME
)
self.playing: bool = False
self.set_main_window_size() self.set_main_window_size()
self.lblSumPlaytime = QLabel("") self.lblSumPlaytime = QLabel("")
@ -167,6 +248,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.widgetFadeVolume.hideAxis("left") self.widgetFadeVolume.hideAxis("left")
self.widgetFadeVolume.setDefaultPadding(0) self.widgetFadeVolume.setDefaultPadding(0)
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND) self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
FadeCurve.GraphWidget = self.widgetFadeVolume
self.active_tab = lambda: self.tabPlaylist.currentWidget() self.active_tab = lambda: self.tabPlaylist.currentWidget()
self.active_proxy_model = lambda: self.tabPlaylist.currentWidget().model() self.active_proxy_model = lambda: self.tabPlaylist.currentWidget().model()
@ -176,6 +258,11 @@ class Window(QMainWindow, Ui_MainWindow):
self.audacity_client: Optional[pipeclient.PipeClient] = None self.audacity_client: Optional[pipeclient.PipeClient] = None
self.initialise_audacity() self.initialise_audacity()
if Config.CARTS_HIDE:
self.cartsWidget.hide()
self.frame_6.hide()
else:
self.carts_init()
self.disable_selection_timing = False self.disable_selection_timing = False
self.clock_counter = 0 self.clock_counter = 0
self.timer10.start(10) self.timer10.start(10)
@ -208,12 +295,142 @@ class Window(QMainWindow, Ui_MainWindow):
QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.Ok,
) )
def cart_configure(self, cart: Carts, btn: CartButton) -> None:
"""Configure button with cart data"""
btn.setEnabled(False)
btn.pgb.setVisible(False)
if cart.path:
if not helpers.file_is_unreadable(cart.path):
colour = Config.COLOUR_CART_READY
btn.path = cart.path
btn.player = self.music.VLC.media_player_new(cart.path)
if btn.player:
btn.player.audio_set_volume(Config.VLC_VOLUME_DEFAULT)
if cart.enabled:
btn.setEnabled(True)
btn.pgb.setVisible(True)
else:
colour = Config.COLOUR_CART_ERROR
else:
colour = Config.COLOUR_CART_UNCONFIGURED
btn.setStyleSheet("background-color: " + colour + ";\n")
if cart.name is not None:
btn.setText(cart.name)
def cart_click(self) -> None:
"""Handle cart click"""
btn = self.sender()
if not isinstance(btn, CartButton):
return
if not helpers.file_is_unreadable(btn.path):
# Don't allow clicks while we're playing
btn.setEnabled(False)
if not btn.player:
log.debug(f"musicmuster.cart_click(): no player assigned ({btn=})")
return
btn.player.play()
btn.is_playing = True
colour = Config.COLOUR_CART_PLAYING
thread = threading.Thread(target=self.cart_progressbar, args=(btn,))
thread.start()
else:
colour = Config.COLOUR_CART_ERROR
btn.setStyleSheet("background-color: " + colour + ";\n")
btn.pgb.setMinimum(0)
def cart_edit(self, btn: CartButton, event: QEvent):
"""Handle context menu for cart button"""
with db.Session() as session:
cart = session.query(Carts).get(btn.cart_id)
if cart is None:
log.error("cart_edit: cart not found")
return
dlg = CartDialog(musicmuster=self, session=session, cart=cart)
if dlg.exec():
name = dlg.ui.lineEditName.text()
if not name:
QMessageBox.warning(self, "Error", "Name required")
return
path = dlg.path
if not path:
QMessageBox.warning(self, "Error", "Filename required")
return
if cart.path and not helpers.file_is_unreadable(cart.path):
tags = helpers.get_tags(cart.path)
cart.duration = tags["duration"]
cart.enabled = dlg.ui.chkEnabled.isChecked()
cart.name = name
cart.path = path
session.add(cart)
session.commit()
self.cart_configure(cart, btn)
def carts_init(self) -> None:
"""Initialse carts data structures"""
with db.Session() as session:
# Number carts from 1 for humanity
for cart_number in range(1, Config.CARTS_COUNT + 1):
cart = session.query(Carts).get(cart_number)
if cart is None:
cart = Carts(session, cart_number, name=f"Cart #{cart_number}")
btn = CartButton(self, cart)
btn.clicked.connect(self.cart_click)
# Insert button on left of cart space starting at
# location zero
self.horizontalLayout_Carts.insertWidget(cart.id - 1, btn)
# Configure button
self.cart_configure(cart, btn)
def cart_progressbar(self, btn: CartButton) -> None:
"""Manage progress bar"""
if not btn.duration:
return
ms = 0
btn.pgb.setMaximum(btn.duration)
while ms <= btn.duration:
btn.progress.emit(ms)
ms += 100
sleep(0.1)
def cart_tick(self) -> None:
"""Cart clock actions"""
for i in range(self.horizontalLayout_Carts.count()):
btn = self.horizontalLayout_Carts.itemAt(i).widget()
if not btn:
continue
if btn.is_playing:
if not btn.player.is_playing():
# Cart has finished playing
btn.is_playing = False
btn.setEnabled(True)
# Setting to position 0 doesn't seem to work
btn.player = self.music.VLC.media_player_new(btn.path)
btn.player.audio_set_volume(Config.VLC_VOLUME_DEFAULT)
colour = Config.COLOUR_CART_READY
btn.setStyleSheet("background-color: " + colour + ";\n")
btn.pgb.setValue(0)
def clear_next(self) -> None: def clear_next(self) -> None:
""" """
Clear next track Clear next track
""" """
track_sequence.next = None track_sequence.next = PlaylistTrack()
self.update_headers() self.update_headers()
def clear_selection(self) -> None: def clear_selection(self) -> None:
@ -232,7 +449,7 @@ class Window(QMainWindow, Ui_MainWindow):
return return
# Don't allow window to close when a track is playing # Don't allow window to close when a track is playing
if track_sequence.current and track_sequence.current.is_playing(): if self.playing:
event.ignore() event.ignore()
helpers.show_warning( helpers.show_warning(
self, "Track playing", "Can't close application while track is playing" self, "Track playing", "Can't close application while track is playing"
@ -304,10 +521,7 @@ class Window(QMainWindow, Ui_MainWindow):
""" """
# Don't close current track playlist # Don't close current track playlist
if track_sequence.current is None: current_track_playlist_id = track_sequence.now.playlist_id
return True
current_track_playlist_id = track_sequence.current.playlist_id
closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
if current_track_playlist_id: if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id: if closing_tab_playlist_id == current_track_playlist_id:
@ -387,7 +601,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals.next_track_changed_signal.connect(self.update_headers) self.signals.next_track_changed_signal.connect(self.update_headers)
self.signals.status_message_signal.connect(self.show_status_message) self.signals.status_message_signal.connect(self.show_status_message)
self.signals.show_warning_signal.connect(self.show_warning) 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.timer10.timeout.connect(self.tick_10ms)
self.timer500.timeout.connect(self.tick_500ms) self.timer500.timeout.connect(self.tick_500ms)
@ -508,8 +721,10 @@ class Window(QMainWindow, Ui_MainWindow):
def drop3db(self) -> None: def drop3db(self) -> None:
"""Drop music level by 3db if button checked""" """Drop music level by 3db if button checked"""
if track_sequence.current: if self.btnDrop3db.isChecked():
track_sequence.current.drop3db(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)
def enable_escape(self, enabled: bool) -> None: def enable_escape(self, enabled: bool) -> None:
""" """
@ -523,38 +738,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.action_Clear_selection.setEnabled(enabled) 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: def export_playlist_tab(self) -> None:
"""Export the current playlist to an m3u file""" """Export the current playlist to an m3u file"""
@ -602,8 +785,7 @@ class Window(QMainWindow, Ui_MainWindow):
def fade(self) -> None: def fade(self) -> None:
"""Fade currently playing track""" """Fade currently playing track"""
if track_sequence.current: self.stop_playing(fade=True)
track_sequence.current.fade()
def hide_played(self): def hide_played(self):
"""Toggle hide played tracks""" """Toggle hide played tracks"""
@ -963,12 +1145,11 @@ class Window(QMainWindow, Ui_MainWindow):
""" """
# Check for inadvertent press of 'return' # Check for inadvertent press of 'return'
if track_sequence.current and self.catch_return_key: if self.catch_return_key:
# Suppress inadvertent double press # Suppress inadvertent double press
if ( if (
track_sequence.current track_sequence.now.start_time
and track_sequence.current.start_time and track_sequence.now.start_time
and track_sequence.current.start_time
+ dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS) + dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS)
> dt.datetime.now() > dt.datetime.now()
): ):
@ -976,8 +1157,8 @@ class Window(QMainWindow, Ui_MainWindow):
# If return is pressed during first PLAY_NEXT_GUARD_MS then # If return is pressed during first PLAY_NEXT_GUARD_MS then
# default to NOT playing the next track, else default to # default to NOT playing the next track, else default to
# playing it. # playing it.
default_yes: bool = track_sequence.current.start_time is not None and ( default_yes: bool = track_sequence.now.start_time is not None and (
(dt.datetime.now() - track_sequence.current.start_time).total_seconds() (dt.datetime.now() - track_sequence.now.start_time).total_seconds()
* 1000 * 1000
> Config.PLAY_NEXT_GUARD_MS > Config.PLAY_NEXT_GUARD_MS
) )
@ -989,30 +1170,32 @@ class Window(QMainWindow, Ui_MainWindow):
): ):
return return
log.debug(f"play_next({position=})") log.info(f"play_next({position=})")
# If there is no next track set, return. # If there is no next track set, return.
if track_sequence.next is None: if not track_sequence.next.track_id:
log.error("musicmuster.play_next(): no next track selected") log.error("musicmuster.play_next(): no next track selected")
return return
if not track_sequence.next.path:
log.error("musicmuster.play_next(): no path for next track")
return
# Issue #223 concerns a very short pause (maybe 0.1s) sometimes # Issue #223 concerns a very short pause (maybe 0.1s) sometimes
# when starting to play at track. # when starting to play at track.
# Resolution appears to be to disable timer10 for the first ten # Resolution appears to be to disable timer10 for the first ten
# seconds of playback. Re-enable in update_clocks. # seconds of playback. Re-enabled tick_1000ms
self.timer10.stop() self.timer10.stop()
log.debug("10ms timer disabled", 0) self.show_status_message("10ms timer disabled", 0)
# If there's currently a track playing, fade it. # If there's currently a track playing, fade it.
if track_sequence.current: self.stop_playing(fade=True)
track_sequence.current.fade()
# Move next track to current track. # Move next track to current track.
# end_of_track_actions() will have saved current track to # stop_playing() above has called end_of_track_actions()
# previous_track # which will have populated self.previous_track
track_sequence.current = track_sequence.next track_sequence.now = track_sequence.next
# Clear next track # Clear next track
self.clear_next() self.clear_next()
@ -1023,19 +1206,22 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnDrop3db.setChecked(False) self.btnDrop3db.setChecked(False)
# Play (new) current track # Play (new) current track
track_sequence.current.play(position) if not track_sequence.now.path:
log.error("No path for next track")
# Update clocks now, don't wait for next tick return
self.update_clocks() self.music.play(track_sequence.now.path, position)
# Show closing volume graph # Show closing volume graph
if track_sequence.current.fade_graph: if track_sequence.now.fade_graph:
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume track_sequence.now.fade_graph.plot()
track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot()
else: else:
log.error("No fade_graph") log.error("No fade_graph")
# Note that track is playing
log.debug("set track_sequence")
track_sequence.now.start()
self.playing = True
# Disable play next controls # Disable play next controls
self.catch_return_key = True self.catch_return_key = True
self.show_status_message("Play controls: Disabled", 0) self.show_status_message("Play controls: Disabled", 0)
@ -1052,7 +1238,7 @@ class Window(QMainWindow, Ui_MainWindow):
for lp in last_played: for lp in last_played:
track = session.get(Tracks, lp.track_id) track = session.get(Tracks, lp.track_id)
tracklist.append(f"{track.title} ({track.artist})") tracklist.append(f"{track.title} ({track.artist})")
tt = "<br>".join(tracklist) tt = "<br>".join(reversed(tracklist))
self.hdrPreviousTrack.setToolTip(tt) self.hdrPreviousTrack.setToolTip(tt)
@ -1062,35 +1248,19 @@ class Window(QMainWindow, Ui_MainWindow):
""" """
if self.btnPreview.isChecked(): if self.btnPreview.isChecked():
# Get track_id for first selected track if there is one # Get track path for first selected track if there is one
row_number_and_track_id = self.active_tab().get_selected_row_and_track_id() track_path = self.active_tab().get_selected_row_track_path()
if row_number_and_track_id: if not track_path:
row_number, track_id = row_number_and_track_id # Otherwise get path to next track to play
else: track_path = track_sequence.next.path
# Otherwise get track_id to next track to play if not track_path:
if track_sequence.next:
track_id = track_sequence.next.track_id
row_number = track_sequence.next.row_number
if not track_id or row_number is None:
self.btnPreview.setChecked(False) self.btnPreview.setChecked(False)
return return
self.preview_player.play(path=track_path)
try:
with db.Session() as session:
self.preview_track_manager = PreviewTrackManager(
session=session, track_id=track_id, row_number=row_number
)
self.preview_track_manager.play()
except ValueError as e:
log.error(f"Error creating PreviewTrackManager({str(e)})")
return
else: else:
if self.preview_track_manager: self.preview_player.stop()
self.preview_track_manager.stop()
self.preview_track_manager = None
self.label_intro_timer.setText("0.0") self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreviewMark.setEnabled(False) self.btnPreviewMark.setEnabled(False)
self.btnPreviewArm.setChecked(False) self.btnPreviewArm.setChecked(False)
@ -1102,45 +1272,51 @@ class Window(QMainWindow, Ui_MainWindow):
def preview_back(self) -> None: def preview_back(self) -> None:
"""Wind back preview file""" """Wind back preview file"""
if self.preview_track_manager: self.preview_player.move_back(Config.PREVIEW_BACK_MS)
self.preview_track_manager.move_back()
def preview_end(self) -> None: def preview_end(self) -> None:
"""Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro""" """Advance preview file to just before end of intro"""
if self.preview_track_manager: return
self.preview_track_manager.move_to_intro_end()
# 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: def preview_fwd(self) -> None:
"""Advance preview file""" """Advance preview file"""
if self.preview_track_manager: self.preview_player.move_forward(Config.PREVIEW_ADVANCE_MS)
self.preview_track_manager.move_forward()
def preview_mark(self) -> None: def preview_mark(self) -> None:
"""Set intro time""" """Set intro time"""
if self.preview_track_manager: track_id = self.active_tab().get_selected_row_track_id()
track_id = self.preview_track_manager.track_id row_number = self.active_tab().get_selected_row()
row_number = self.preview_track_manager.row_number if track_id:
with db.Session() as session: with db.Session() as session:
track = session.get(Tracks, track_id) track = session.get(Tracks, track_id)
if track: if track:
# Save intro as millisends rounded to nearest 0.1 # Save intro as millisends rounded to nearest 0.1
# second because editor spinbox only resolves to 0.1 # second because editor spinbox only resolves to 0.1
# seconds # seconds
intro = round(self.preview_track_manager.time_playing() / 100) * 100 track.intro = round(self.preview_player.get_playtime() / 100) * 100
track.intro = intro
session.commit() session.commit()
self.preview_track_manager.intro = intro
self.active_tab().source_model.refresh_row(session, row_number) self.active_tab().source_model.refresh_row(session, row_number)
self.active_tab().source_model.invalidate_row(row_number) self.active_tab().source_model.invalidate_row(row_number)
def preview_start(self) -> None: def preview_start(self) -> None:
"""Restart preview""" """Advance preview file"""
if self.preview_track_manager: self.preview_player.set_position(0)
self.preview_track_manager.restart()
def rename_playlist(self) -> None: def rename_playlist(self) -> None:
""" """
@ -1238,8 +1414,7 @@ class Window(QMainWindow, Ui_MainWindow):
- If a track is playing, make that the next track - If a track is playing, make that the next track
""" """
if not track_sequence.previous: log.info("resume()")
return
# Return if no saved position # Return if no saved position
if not track_sequence.previous.resume_marker: if not track_sequence.previous.resume_marker:
@ -1257,15 +1432,12 @@ class Window(QMainWindow, Ui_MainWindow):
# We need to fake the start time to reflect where we resumed the # We need to fake the start time to reflect where we resumed the
# track # track
if ( if (
track_sequence.current track_sequence.now.start_time
and track_sequence.current.start_time and track_sequence.now.duration
and track_sequence.current.duration and track_sequence.now.resume_marker
and track_sequence.current.resume_marker
): ):
elapsed_ms = ( elapsed_ms = track_sequence.now.duration * track_sequence.now.resume_marker
track_sequence.current.duration * track_sequence.current.resume_marker track_sequence.now.start_time -= dt.timedelta(milliseconds=elapsed_ms)
)
track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def save_as_template(self) -> None: def save_as_template(self) -> None:
"""Save current playlist as template""" """Save current playlist as template"""
@ -1391,8 +1563,7 @@ class Window(QMainWindow, Ui_MainWindow):
def show_current(self) -> None: def show_current(self) -> None:
"""Scroll to show current track""" """Scroll to show current track"""
if track_sequence.current: self.show_track(track_sequence.now)
self.show_track(track_sequence.current)
def show_warning(self, title: str, body: str) -> None: def show_warning(self, title: str, body: str) -> None:
""" """
@ -1405,7 +1576,6 @@ class Window(QMainWindow, Ui_MainWindow):
def show_next(self) -> None: def show_next(self) -> None:
"""Scroll to show next track""" """Scroll to show next track"""
if track_sequence.next:
self.show_track(track_sequence.next) self.show_track(track_sequence.next)
def show_status_message(self, message: str, timing: int) -> None: def show_status_message(self, message: str, timing: int) -> None:
@ -1415,27 +1585,25 @@ class Window(QMainWindow, Ui_MainWindow):
self.statusbar.showMessage(message, timing) self.statusbar.showMessage(message, timing)
def show_track(self, playlist_track: MainTrackManager) -> None: def show_track(self, plt: PlaylistTrack) -> None:
"""Scroll to show track in plt""" """Scroll to show track in plt"""
# Switch to the correct tab # Switch to the correct tab
playlist_id = playlist_track.playlist_id plt_playlist_id = plt.playlist_id
if not playlist_id: if not plt_playlist_id:
# No playlist # No playlist
return return
if playlist_id != self.active_tab().playlist_id: if plt_playlist_id != self.active_tab().playlist_id:
for idx in range(self.tabPlaylist.count()): for idx in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(idx).playlist_id == playlist_id: if self.tabPlaylist.widget(idx).playlist_id == plt_playlist_id:
self.tabPlaylist.setCurrentIndex(idx) self.tabPlaylist.setCurrentIndex(idx)
break break
display_row = ( display_row = (
self.active_proxy_model() self.active_proxy_model()
.mapFromSource( .mapFromSource(
self.active_proxy_model().source_model.index( self.active_proxy_model().source_model.index(plt.plr_rownum, 0)
playlist_track.row_number, 0
)
) )
.row() .row()
) )
@ -1471,8 +1639,66 @@ class Window(QMainWindow, Ui_MainWindow):
def stop(self) -> None: def stop(self) -> None:
"""Stop playing immediately""" """Stop playing immediately"""
if track_sequence.current: self.stop_playing(fade=False)
track_sequence.current.stop()
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)
def tab_change(self): def tab_change(self):
"""Called when active tab changed""" """Called when active tab changed"""
@ -1484,22 +1710,42 @@ class Window(QMainWindow, Ui_MainWindow):
Called every 10ms Called every 10ms
""" """
if track_sequence.current: # Update volume fade curve
track_sequence.current.update_fade_graph() if (
track_sequence.now.fade_graph_start_updates is None
or track_sequence.now.fade_graph_start_updates > dt.datetime.now()
):
return
if (
track_sequence.now.track_id
and track_sequence.now.fade_graph
and track_sequence.now.start_time
):
play_time = (
dt.datetime.now() - track_sequence.now.start_time
).total_seconds() * 1000
track_sequence.now.fade_graph.tick(play_time)
def tick_500ms(self) -> None:
"""
Called every 500ms
"""
self.lblTOD.setText(dt.datetime.now().strftime(Config.TOD_TIME_FORMAT))
# Update carts
# self.cart_tick()
def tick_100ms(self) -> None: def tick_100ms(self) -> None:
""" """
Called every 100ms Called every 100ms
""" """
if track_sequence.current:
try:
track_sequence.current.check_for_end_of_track()
# Update intro counter if applicable and, if updated, return # Update intro counter if applicable and, if updated, return
# because playing an intro takes precedence over timing a # because playing an intro takes precedence over timing a
# preview. # preview.
remaining_ms = track_sequence.current.time_remaining_intro() if self.music.is_playing() and track_sequence.now.intro:
remaining_ms = track_sequence.now.intro - self.music.get_playtime()
if remaining_ms > 0: if remaining_ms > 0:
self.label_intro_timer.setText(f"{remaining_ms / 1000:.1f}") self.label_intro_timer.setText(f"{remaining_ms / 1000:.1f}")
if remaining_ms <= Config.INTRO_SECONDS_WARNING_MS: if remaining_ms <= Config.INTRO_SECONDS_WARNING_MS:
@ -1509,28 +1755,24 @@ class Window(QMainWindow, Ui_MainWindow):
return return
else: else:
self.label_intro_timer.setStyleSheet("") self.label_intro_timer.setStyleSheet("")
except AttributeError:
# currnent track ended during servicing tick
pass
# Ensure preview button is reset if preview finishes playing # Ensure preview button is reset if preview finishes playing
if self.preview_track_manager: self.btnPreview.setChecked(self.preview_player.is_playing())
self.btnPreview.setChecked(self.preview_track_manager.is_playing())
# Update preview timer # Update preview timer
if self.preview_track_manager.is_playing(): if self.preview_player.is_playing():
playtime = self.preview_track_manager.time_playing() playtime = self.preview_player.get_playtime()
self.label_intro_timer.setText(f"{playtime / 1000:.1f}") self.label_intro_timer.setText(f"{playtime / 1000:.1f}")
if self.preview_track_manager.time_remaining_intro() <= 50: 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( self.label_intro_timer.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}" f"background: {Config.COLOUR_WARNING_TIMER}"
) )
else:
self.label_intro_timer.setStyleSheet("")
else: else:
self.label_intro_timer.setText("0.0") self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False)
def tick_1000ms(self) -> None: def tick_1000ms(self) -> None:
""" """
@ -1540,41 +1782,28 @@ class Window(QMainWindow, Ui_MainWindow):
# Only update play clocks once a second so that their updates # Only update play clocks once a second so that their updates
# are synchronised (otherwise it looks odd) # are synchronised (otherwise it looks odd)
self.update_clocks() if not self.playing:
return
def tick_500ms(self) -> None:
"""
Called every 500ms
"""
self.lblTOD.setText(dt.datetime.now().strftime(Config.TOD_TIME_FORMAT))
def update_clocks(self) -> None:
"""
Update track clocks.
"""
# If track is playing, update track clocks time and colours # If track is playing, update track clocks time and colours
if track_sequence.current and track_sequence.current.is_playing(): if self.music.player and self.music.player.is_playing():
# see play_next() and issue #223. playtime = self.music.get_playtime()
# TODO: find a better way of handling this time_to_fade = track_sequence.now.fade_at - playtime
if ( time_to_silence = track_sequence.now.silence_at - playtime
track_sequence.current.time_playing() > 10000
and not self.timer10.isActive() # see play_next() and issue #223
): if playtime > 10000 and not self.timer10.isActive():
self.timer10.start(10) self.timer10.start(10)
log.debug("10ms timer enabled") self.show_status_message("10ms timer enabled", 0)
# Elapsed time # Elapsed time
self.label_elapsed_timer.setText( self.label_elapsed_timer.setText(
helpers.ms_to_mmss(track_sequence.current.time_playing()) helpers.ms_to_mmss(playtime)
+ " / " + " / "
+ helpers.ms_to_mmss(track_sequence.current.duration) + helpers.ms_to_mmss(track_sequence.now.duration)
) )
# Time to fade # 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)) self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade))
# If silent in the next 5 seconds, put warning colour on # If silent in the next 5 seconds, put warning colour on
@ -1585,13 +1814,11 @@ class Window(QMainWindow, Ui_MainWindow):
self.frame_silent.setStyleSheet(css_silence) self.frame_silent.setStyleSheet(css_silence)
self.catch_return_key = False self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0) self.show_status_message("Play controls: Enabled", 0)
# Set warning colour on time to silence box when fade starts # Set warning colour on time to silence box when fade starts
elif time_to_fade <= 500: elif time_to_fade <= 500:
css_fade = f"background: {Config.COLOUR_WARNING_TIMER}" css_fade = f"background: {Config.COLOUR_WARNING_TIMER}"
if self.frame_silent.styleSheet() != css_fade: if self.frame_silent.styleSheet() != css_fade:
self.frame_silent.setStyleSheet(css_fade) self.frame_silent.setStyleSheet(css_fade)
# Five seconds before fade starts, set warning colour on # Five seconds before fade starts, set warning colour on
# time to silence box and enable play controls # time to silence box and enable play controls
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE: elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
@ -1606,27 +1833,34 @@ class Window(QMainWindow, Ui_MainWindow):
self.label_silent_timer.setText(helpers.ms_to_mmss(time_to_silence)) 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: def update_headers(self) -> None:
""" """
Update last / current / next track headers Update last / current / next track headers
""" """
if track_sequence.previous: if track_sequence.previous.title and track_sequence.previous.artist:
self.hdrPreviousTrack.setText( self.hdrPreviousTrack.setText(
f"{track_sequence.previous.title} - {track_sequence.previous.artist}" f"{track_sequence.previous.title} - {track_sequence.previous.artist}"
) )
else: else:
self.hdrPreviousTrack.setText("") self.hdrPreviousTrack.setText("")
if track_sequence.current: if track_sequence.now.title and track_sequence.now.artist:
self.hdrCurrentTrack.setText( self.hdrCurrentTrack.setText(
f"{track_sequence.current.title.replace('&', '&&')} - " f"{track_sequence.now.title.replace('&', '&&')} - "
f"{track_sequence.current.artist.replace('&', '&&')}" f"{track_sequence.now.artist.replace('&', '&&')}"
) )
else: else:
self.hdrCurrentTrack.setText("") self.hdrCurrentTrack.setText("")
if track_sequence.next: if track_sequence.next.title and track_sequence.next.artist:
self.hdrNextTrack.setText( self.hdrNextTrack.setText(
f"{track_sequence.next.title.replace('&', '&&')} - " f"{track_sequence.next.title.replace('&', '&&')} - "
f"{track_sequence.next.artist.replace('&', '&&')}" f"{track_sequence.next.artist.replace('&', '&&')}"
@ -1635,6 +1869,44 @@ class Window(QMainWindow, Ui_MainWindow):
self.hdrNextTrack.setText("") self.hdrNextTrack.setText("")
class CartDialog(QDialog):
"""Edit cart details"""
def __init__(
self, musicmuster: Window, session: Session, cart: Carts, *args, **kwargs
) -> None:
"""
Manage carts
"""
super().__init__(*args, **kwargs)
self.musicmuster = musicmuster
self.session = session
self.ui = Ui_DialogCartEdit()
self.ui.setupUi(self)
self.path = cart.path
self.ui.lblPath.setText(self.path)
self.ui.lineEditName.setText(cart.name)
self.ui.chkEnabled.setChecked(cart.enabled)
self.setWindowTitle("Edit Cart " + str(cart.id))
self.ui.btnFile.clicked.connect(self.choose_file)
def choose_file(self) -> None:
"""File select"""
dlg = QFileDialog()
dlg.setFileMode(QFileDialog.FileMode.ExistingFile)
dlg.setViewMode(QFileDialog.ViewMode.Detail)
dlg.setDirectory(Config.CART_DIRECTORY)
dlg.setNameFilter("Music files (*.flac *.mp3)")
if dlg.exec():
self.path = dlg.selectedFiles()[0]
self.ui.lblPath.setText(self.path)
class DownloadCSV(QDialog): class DownloadCSV(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__() super().__init__()

View File

@ -30,7 +30,7 @@ import obswebsocket # type: ignore
# import snoop # type: ignore # import snoop # type: ignore
# App imports # App imports
from classes import Col, MusicMusterSignals from classes import Col, track_sequence, MusicMusterSignals, PlaylistTrack
from config import Config from config import Config
from helpers import ( from helpers import (
file_is_unreadable, file_is_unreadable,
@ -41,17 +41,13 @@ from helpers import (
) )
from log import log from log import log
from models import db, NoteColours, Playdates, PlaylistRows, Tracks from models import db, NoteColours, Playdates, PlaylistRows, Tracks
from trackmanager import (
MainTrackManager,
track_sequence,
)
HEADER_NOTES_COLUMN = 1 HEADER_NOTES_COLUMN = 1
scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]") scene_change_re = re.compile(r"SetScene=\[([^[\]]*)\]")
class _PlaylistRowData: class PlaylistRowData:
def __init__(self, plr: PlaylistRows) -> None: def __init__(self, plr: PlaylistRows) -> None:
""" """
Populate PlaylistRowData from database PlaylistRows record Populate PlaylistRowData from database PlaylistRows record
@ -121,7 +117,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_id = playlist_id self.playlist_id = playlist_id
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.playlist_rows: dict[int, _PlaylistRowData] = {} self.playlist_rows: dict[int, PlaylistRowData] = {}
self.signals = MusicMusterSignals() self.signals = MusicMusterSignals()
self.played_tracks_hidden = False self.played_tracks_hidden = False
@ -182,7 +178,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def background_role(self, row: int, column: int, prd: _PlaylistRowData) -> QBrush: def background_role(self, row: int, column: int, prd: PlaylistRowData) -> QBrush:
"""Return background setting""" """Return background setting"""
# Handle entire row colouring # Handle entire row colouring
@ -199,10 +195,10 @@ class PlaylistModel(QAbstractTableModel):
if file_is_unreadable(prd.path): if file_is_unreadable(prd.path):
return QBrush(QColor(Config.COLOUR_UNREADABLE)) return QBrush(QColor(Config.COLOUR_UNREADABLE))
# Current track # Current track
if track_sequence.current and track_sequence.current.track_id == prd.track_id: if prd.plrid == track_sequence.now.plr_id:
return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_CURRENT_PLAYLIST))
# Next track # Next track
if track_sequence.next and track_sequence.next.track_id == prd.track_id: if prd.plrid == track_sequence.next.plr_id:
return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST)) return QBrush(QColor(Config.COLOUR_NEXT_PLAYLIST))
# Individual cell colouring # Individual cell colouring
@ -254,10 +250,24 @@ class PlaylistModel(QAbstractTableModel):
- find next track - find next track
""" """
if not track_sequence.current: row_number = track_sequence.now.plr_rownum
return if row_number is not None:
prd = self.playlist_rows[row_number]
else:
prd = None
row_number = track_sequence.current.row_number # Sanity check
if not track_sequence.now.track_id:
log.error(
"playlistmodel:current_track_started called with no current track"
)
return
if row_number is None:
log.error(
"playlistmodel:current_track_started called with no row number "
f"({track_sequence.now=})"
)
return
# Check for OBS scene change # Check for OBS scene change
log.debug("Call OBS scene change") log.debug("Call OBS scene change")
@ -266,23 +276,29 @@ class PlaylistModel(QAbstractTableModel):
with db.Session() as session: with db.Session() as session:
# Update Playdates in database # Update Playdates in database
log.debug("update playdates") log.debug("update playdates")
Playdates(session, track_sequence.current.track_id) Playdates(session, track_sequence.now.track_id)
# Mark track as played in playlist # Mark track as played in playlist
log.debug("Mark track as played") log.debug("Mark track as played")
plr = session.get(PlaylistRows, track_sequence.current.plr_id) plr = session.get(PlaylistRows, track_sequence.now.plr_id)
if plr: if plr:
plr.played = True plr.played = True
self.refresh_row(session, plr.plr_rownum) self.refresh_row(session, plr.plr_rownum)
else: else:
log.error(f"Can't retrieve plr, {track_sequence.current.plr_id=}") log.error(f"Can't retrieve plr, {track_sequence.now.plr_id=}")
# Update track times
log.debug("Update track times")
if prd:
prd.start_time = track_sequence.now.start_time
prd.end_time = track_sequence.now.end_time
# Update colour and times for current row # Update colour and times for current row
self.invalidate_row(row_number) self.invalidate_row(row_number)
# Update previous row in case we're hiding played rows # Update previous row in case we're hiding played rows
if track_sequence.previous and track_sequence.previous.row_number: if track_sequence.previous.plr_rownum:
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.plr_rownum)
# Update all other track times # Update all other track times
self.update_track_times() self.update_track_times()
@ -377,11 +393,13 @@ class PlaylistModel(QAbstractTableModel):
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
def display_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def display_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return text for display Return text for display
""" """
log.debug(f"display_role({row=}, {column=}")
# Set / reset column span # Set / reset column span
if column == HEADER_NOTES_COLUMN: if column == HEADER_NOTES_COLUMN:
column_span = 1 column_span = 1
@ -448,7 +466,7 @@ class PlaylistModel(QAbstractTableModel):
super().endResetModel() super().endResetModel()
self.reset_track_sequence_row_numbers() self.reset_track_sequence_row_numbers()
def edit_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def edit_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return text for editing Return text for editing
""" """
@ -492,7 +510,7 @@ class PlaylistModel(QAbstractTableModel):
return default return default
def font_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def font_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return font Return font
""" """
@ -551,7 +569,7 @@ class PlaylistModel(QAbstractTableModel):
log.debug(f"get_new_row_number() return: {new_row_number=}") log.debug(f"get_new_row_number() return: {new_row_number=}")
return new_row_number return new_row_number
def get_row_info(self, row_number: int) -> _PlaylistRowData: def get_row_info(self, row_number: int) -> PlaylistRowData:
""" """
Return info about passed row Return info about passed row
""" """
@ -641,7 +659,7 @@ class PlaylistModel(QAbstractTableModel):
return QVariant() return QVariant()
def header_text(self, prd: _PlaylistRowData) -> str: def header_text(self, prd: PlaylistRowData) -> str:
""" """
Process possible section timing directives embeded in header Process possible section timing directives embeded in header
""" """
@ -685,16 +703,16 @@ class PlaylistModel(QAbstractTableModel):
# calculate end time if all tracks are played. # calculate end time if all tracks are played.
end_time_str = "" end_time_str = ""
if ( if (
track_sequence.current track_sequence.now.plr_rownum
and track_sequence.current.end_time and track_sequence.now.end_time
and ( and (
row_number row_number
< track_sequence.current.row_number < track_sequence.now.plr_rownum
< prd.plr_rownum < prd.plr_rownum
) )
): ):
section_end_time = ( section_end_time = (
track_sequence.current.end_time track_sequence.now.end_time
+ dt.timedelta(milliseconds=duration) + dt.timedelta(milliseconds=duration)
) )
end_time_str = ( end_time_str = (
@ -812,7 +830,7 @@ class PlaylistModel(QAbstractTableModel):
return self.playlist_rows[row_number].played return self.playlist_rows[row_number].played
def is_track_in_playlist(self, track_id: int) -> Optional[_PlaylistRowData]: def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
""" """
If this track_id is in the playlist, return the PlaylistRowData object If this track_id is in the playlist, return the PlaylistRowData object
else return None else return None
@ -888,16 +906,14 @@ class PlaylistModel(QAbstractTableModel):
row_map[old_row] = new_row row_map[old_row] = new_row
# Check to see whether any rows in track_sequence have moved # Check to see whether any rows in track_sequence have moved
if track_sequence.previous and track_sequence.previous.row_number in row_map: if track_sequence.previous.plr_rownum in row_map:
track_sequence.previous.row_number = row_map[ track_sequence.previous.plr_rownum = row_map[
track_sequence.previous.row_number track_sequence.previous.plr_rownum
] ]
if track_sequence.current and track_sequence.current.row_number in row_map: if track_sequence.now.plr_rownum in row_map:
track_sequence.current.row_number = row_map[ track_sequence.now.plr_rownum = row_map[track_sequence.now.plr_rownum]
track_sequence.current.row_number if track_sequence.next.plr_rownum in row_map:
] track_sequence.next.plr_rownum = row_map[track_sequence.next.plr_rownum]
if track_sequence.next and track_sequence.next.row_number in row_map:
track_sequence.next.row_number = row_map[track_sequence.next.row_number]
# For SQLAlchemy, build a list of dictionaries that map plrid to # For SQLAlchemy, build a list of dictionaries that map plrid to
# new row number: # new row number:
@ -957,10 +973,7 @@ class PlaylistModel(QAbstractTableModel):
self.playlist_id, self.playlist_id,
[self.playlist_rows[a].plrid for a in row_group], [self.playlist_rows[a].plrid for a in row_group],
): ):
if ( if plr.id == track_sequence.now.plr_id:
track_sequence.current
and plr.id == track_sequence.current.plr_id
):
# Don't move current track # Don't move current track
continue continue
plr.playlist_id = to_playlist_id plr.playlist_id = to_playlist_id
@ -981,7 +994,7 @@ class PlaylistModel(QAbstractTableModel):
self.update_track_times() self.update_track_times()
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_prd: _PlaylistRowData, note: str self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None: ) -> None:
""" """
Move existing_prd track to new_row_number and append note to any existing note Move existing_prd track to new_row_number and append note to any existing note
@ -1005,10 +1018,7 @@ class PlaylistModel(QAbstractTableModel):
self.signals.resize_rows_signal.emit(self.playlist_id) self.signals.resize_rows_signal.emit(self.playlist_id)
def move_track_to_header( def move_track_to_header(
self, self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
header_row_number: int,
existing_prd: _PlaylistRowData,
note: Optional[str],
) -> None: ) -> None:
""" """
Add the existing_prd track details to the existing header at header_row_number Add the existing_prd track details to the existing header at header_row_number
@ -1070,10 +1080,10 @@ class PlaylistModel(QAbstractTableModel):
log.info("previous_track_ended()") log.info("previous_track_ended()")
# Sanity check # Sanity check
if not track_sequence.previous: if not track_sequence.previous.track_id:
log.error("playlistmodel:previous_track_ended called with no current track") log.error("playlistmodel:previous_track_ended called with no current track")
return return
if track_sequence.previous.row_number is None: if track_sequence.previous.plr_rownum is None:
log.error( log.error(
"playlistmodel:previous_track_ended called with no row number " "playlistmodel:previous_track_ended called with no row number "
f"({track_sequence.previous=})" f"({track_sequence.previous=})"
@ -1081,7 +1091,7 @@ class PlaylistModel(QAbstractTableModel):
return return
# Update display # Update display
self.invalidate_row(track_sequence.previous.row_number) self.invalidate_row(track_sequence.previous.plr_rownum)
def refresh_data(self, session: db.session): def refresh_data(self, session: db.session):
"""Populate dicts for data calls""" """Populate dicts for data calls"""
@ -1089,13 +1099,13 @@ class PlaylistModel(QAbstractTableModel):
# Populate self.playlist_rows with playlist data # Populate self.playlist_rows with playlist data
self.playlist_rows.clear() self.playlist_rows.clear()
for p in PlaylistRows.deep_rows(session, self.playlist_id): for p in PlaylistRows.deep_rows(session, self.playlist_id):
self.playlist_rows[p.plr_rownum] = _PlaylistRowData(p) self.playlist_rows[p.plr_rownum] = PlaylistRowData(p)
def refresh_row(self, session, row_number): def refresh_row(self, session, row_number):
"""Populate dict for one row from database""" """Populate dict for one row from database"""
p = PlaylistRows.deep_row(session, self.playlist_id, row_number) p = PlaylistRows.deep_row(session, self.playlist_id, row_number)
self.playlist_rows[row_number] = _PlaylistRowData(p) self.playlist_rows[row_number] = PlaylistRowData(p)
def remove_track(self, row_number: int) -> None: def remove_track(self, row_number: int) -> None:
""" """
@ -1135,23 +1145,21 @@ class PlaylistModel(QAbstractTableModel):
log.debug("reset_track_sequence_row_numbers()") log.debug("reset_track_sequence_row_numbers()")
# Check the track_sequence next, current and previous plrs and # Check the track_sequence next, now and previous plrs and
# update the row number # update the row number
with db.Session() as session: with db.Session() as session:
if track_sequence.next and track_sequence.next.row_number: if track_sequence.next.plr_rownum:
next_plr = session.get(PlaylistRows, track_sequence.next.row_number) next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if next_plr: if next_plr:
track_sequence.next.row_number = next_plr.plr_rownum track_sequence.next.plr_rownum = next_plr.plr_rownum
if track_sequence.current and track_sequence.current.row_number: if track_sequence.now.plr_rownum:
now_plr = session.get(PlaylistRows, track_sequence.current.row_number) now_plr = session.get(PlaylistRows, track_sequence.now.plr_id)
if now_plr: if now_plr:
track_sequence.current.row_number = now_plr.plr_rownum track_sequence.now.plr_rownum = now_plr.plr_rownum
if track_sequence.previous and track_sequence.previous.row_number: if track_sequence.previous.plr_rownum:
previous_plr = session.get( previous_plr = session.get(PlaylistRows, track_sequence.previous.plr_id)
PlaylistRows, track_sequence.previous.row_number
)
if previous_plr: if previous_plr:
track_sequence.previous.row_number = previous_plr.plr_rownum track_sequence.previous.plr_rownum = previous_plr.plr_rownum
self.update_track_times() self.update_track_times()
@ -1227,69 +1235,64 @@ class PlaylistModel(QAbstractTableModel):
return True return True
def set_next_row(self, row_number: Optional[int]) -> bool: def set_next_row(self, row_number: Optional[int]) -> None:
""" """
Set row_number as next track. If row_number is None, clear next track. Set row_number as next track. If row_number is None, clear next track.
Return True if successful else False.
""" """
log.debug(f"set_next_row({row_number=})") log.info(f"set_next_row({row_number=})")
next_row_was = track_sequence.next.plr_rownum
if row_number is None: if row_number is None:
# Clear next track if next_row_was is None:
if track_sequence.next: return
track_sequence.next = None track_sequence.next = PlaylistTrack()
else: self.signals.next_track_changed_signal.emit()
return True return
else:
# Get plrid of row # Update track_sequence
with db.Session() as session:
track_sequence.next = PlaylistTrack()
try: try:
prd = self.playlist_rows[row_number] plrid = self.playlist_rows[row_number].plrid
except IndexError: except IndexError:
log.error( log.error(
f"playlistmodel.set_next_track({row_number=}, " f"playlistmodel.set_next_track({row_number=}, "
f"{self.playlist_id=}" f"{self.playlist_id=}"
"IndexError"
) )
return False return
if prd.track_id is None or prd.plr_rownum is None: plr = session.get(PlaylistRows, plrid)
if plr:
# Check this isn't a header row
if self.is_header_row(row_number):
log.error( log.error(
"Tried to set next row on header row: "
f"playlistmodel.set_next_track({row_number=}, " f"playlistmodel.set_next_track({row_number=}, "
"No track / row number " f"{self.playlist_id=}"
f"{self.playlist_id=}, {prd.track_id=}, {prd.plr_rownum=}"
) )
return False return
# Check track is readable
old_next_row: Optional[int] = None if file_is_unreadable(plr.track.path):
if track_sequence.next: log.error(
old_next_row = track_sequence.next.row_number "Tried to set next row on unreadable row: "
f"playlistmodel.set_next_track({row_number=}, "
with db.Session() as session: f"{self.playlist_id=}"
try: )
track_sequence.next = MainTrackManager(session, prd.plrid) return
self.invalidate_row(row_number) track_sequence.next.set_plr(session, plr)
except ValueError as e: self.signals.next_track_changed_signal.emit()
log.error(f"Error creating MainTrackManager({prd=}): ({str(e)})")
return False
self.signals.search_wikipedia_signal.emit( self.signals.search_wikipedia_signal.emit(
self.playlist_rows[row_number].title self.playlist_rows[row_number].title
) )
if old_next_row:
self.invalidate_row(old_next_row)
self.invalidate_row(row_number) self.invalidate_row(row_number)
self.signals.next_track_changed_signal.emit() if next_row_was is not None:
self.invalidate_row(next_row_was)
self.update_track_times() self.update_track_times()
return True
def setData( def setData(
self, self, index: QModelIndex, value: str | float, role: int = Qt.ItemDataRole.EditRole
index: QModelIndex,
value: str | float,
role: int = Qt.ItemDataRole.EditRole,
) -> bool: ) -> bool:
""" """
Update model with edited data Update model with edited data
@ -1393,7 +1396,7 @@ class PlaylistModel(QAbstractTableModel):
def supportedDropActions(self) -> Qt.DropAction: def supportedDropActions(self) -> Qt.DropAction:
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
def tooltip_role(self, row: int, column: int, prd: _PlaylistRowData) -> QVariant: def tooltip_role(self, row: int, column: int, prd: PlaylistRowData) -> QVariant:
""" """
Return tooltip. Currently only used for last_played column. Return tooltip. Currently only used for last_played column.
""" """
@ -1409,7 +1412,7 @@ class PlaylistModel(QAbstractTableModel):
"<br>".join( "<br>".join(
[ [
a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT) a.lastplayed.strftime(Config.LAST_PLAYED_TOOLTIP_DATE_FORMAT)
for a in reversed(playdates) for a in playdates
] ]
) )
) )
@ -1431,22 +1434,21 @@ class PlaylistModel(QAbstractTableModel):
prd = self.playlist_rows[row_number] prd = self.playlist_rows[row_number]
# Reset start_time if this is the current row # Reset start_time if this is the current row
if track_sequence.current: if row_number == track_sequence.now.plr_rownum:
if row_number == track_sequence.current.row_number: prd.start_time = track_sequence.now.start_time
prd.start_time = track_sequence.current.start_time prd.end_time = track_sequence.now.end_time
prd.end_time = track_sequence.current.end_time
update_rows.append(row_number) update_rows.append(row_number)
if not next_start_time: if not next_start_time:
next_start_time = prd.end_time next_start_time = prd.end_time
continue continue
# Set start time for next row if we have a current track # Set start time for next row if we have a current track
if track_sequence.next and track_sequence.current.end_time: if (
if row_number == track_sequence.next.row_number: row_number == track_sequence.next.plr_rownum
prd.start_time = track_sequence.current.end_time and track_sequence.now.end_time
prd.end_time = prd.start_time + dt.timedelta( ):
milliseconds=prd.duration prd.start_time = track_sequence.now.end_time
) prd.end_time = prd.start_time + dt.timedelta(milliseconds=prd.duration)
next_start_time = prd.end_time next_start_time = prd.end_time
update_rows.append(row_number) update_rows.append(row_number)
continue continue
@ -1458,11 +1460,11 @@ class PlaylistModel(QAbstractTableModel):
# If we're between the current and next row, zero out # If we're between the current and next row, zero out
# times # times
if ( if (
track_sequence.current track_sequence.now.plr_rownum is not None
and track_sequence.next and track_sequence.next.plr_rownum is not None
and track_sequence.current.row_number and track_sequence.now.plr_rownum
< row_number < row_number
< track_sequence.next.row_number < track_sequence.next.plr_rownum
): ):
prd.start_time = None prd.start_time = None
prd.end_time = None prd.end_time = None
@ -1537,7 +1539,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
if self.source_model.is_played_row(source_row): if self.source_model.is_played_row(source_row):
# Don't hide current or next track # Don't hide current or next track
with db.Session() as session: with db.Session() as session:
if track_sequence.next: if track_sequence.next.plr_id:
next_plr = session.get(PlaylistRows, track_sequence.next.plr_id) next_plr = session.get(PlaylistRows, track_sequence.next.plr_id)
if ( if (
next_plr next_plr
@ -1545,10 +1547,8 @@ class PlaylistProxyModel(QSortFilterProxyModel):
and next_plr.playlist_id == self.source_model.playlist_id and next_plr.playlist_id == self.source_model.playlist_id
): ):
return True return True
if track_sequence.current: if track_sequence.now.plr_id:
now_plr = session.get( now_plr = session.get(PlaylistRows, track_sequence.now.plr_id)
PlaylistRows, track_sequence.current.plr_id
)
if ( if (
now_plr now_plr
and now_plr.plr_rownum == source_row and now_plr.plr_rownum == source_row
@ -1558,20 +1558,19 @@ class PlaylistProxyModel(QSortFilterProxyModel):
# Don't hide previous track until # Don't hide previous track until
# HIDE_AFTER_PLAYING_OFFSET milliseconds after # HIDE_AFTER_PLAYING_OFFSET milliseconds after
# current track has started # current track has started
if track_sequence.previous: if track_sequence.previous.plr_id:
previous_plr = session.get( previous_plr = session.get(
PlaylistRows, track_sequence.previous.plr_id PlaylistRows, track_sequence.previous.plr_id
) )
if ( if (
track_sequence.current previous_plr
and previous_plr
and previous_plr.plr_rownum == source_row and previous_plr.plr_rownum == source_row
and previous_plr.playlist_id and previous_plr.playlist_id
== self.source_model.playlist_id == self.source_model.playlist_id
): ):
if track_sequence.current.start_time: if track_sequence.now.start_time:
if dt.datetime.now() > ( if dt.datetime.now() > (
track_sequence.current.start_time track_sequence.now.start_time
+ dt.timedelta( + dt.timedelta(
milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET milliseconds=Config.HIDE_AFTER_PLAYING_OFFSET
) )
@ -1624,7 +1623,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def get_rows_duration(self, row_numbers: List[int]) -> int: def get_rows_duration(self, row_numbers: List[int]) -> int:
return self.source_model.get_rows_duration(row_numbers) return self.source_model.get_rows_duration(row_numbers)
def get_row_info(self, row_number: int) -> _PlaylistRowData: def get_row_info(self, row_number: int) -> PlaylistRowData:
return self.source_model.get_row_info(row_number) return self.source_model.get_row_info(row_number)
def get_row_track_path(self, row_number: int) -> str: def get_row_track_path(self, row_number: int) -> str:
@ -1650,7 +1649,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def is_played_row(self, row_number: int) -> bool: def is_played_row(self, row_number: int) -> bool:
return self.source_model.is_played_row(row_number) return self.source_model.is_played_row(row_number)
def is_track_in_playlist(self, track_id: int) -> Optional[_PlaylistRowData]: def is_track_in_playlist(self, track_id: int) -> Optional[PlaylistRowData]:
return self.source_model.is_track_in_playlist(track_id) return self.source_model.is_track_in_playlist(track_id)
def mark_unplayed(self, row_numbers: List[int]) -> None: def mark_unplayed(self, row_numbers: List[int]) -> None:
@ -1667,15 +1666,12 @@ class PlaylistProxyModel(QSortFilterProxyModel):
) )
def move_track_add_note( def move_track_add_note(
self, new_row_number: int, existing_prd: _PlaylistRowData, note: str self, new_row_number: int, existing_prd: PlaylistRowData, note: str
) -> None: ) -> None:
return self.source_model.move_track_add_note(new_row_number, existing_prd, note) return self.source_model.move_track_add_note(new_row_number, existing_prd, note)
def move_track_to_header( def move_track_to_header(
self, self, header_row_number: int, existing_prd: PlaylistRowData, note: Optional[str]
header_row_number: int,
existing_prd: _PlaylistRowData,
note: Optional[str],
) -> None: ) -> None:
return self.source_model.move_track_to_header( return self.source_model.move_track_to_header(
header_row_number, existing_prd, note header_row_number, existing_prd, note
@ -1690,7 +1686,7 @@ class PlaylistProxyModel(QSortFilterProxyModel):
def rescan_track(self, row_number: int) -> None: def rescan_track(self, row_number: int) -> None:
return self.source_model.rescan_track(row_number) return self.source_model.rescan_track(row_number)
def set_next_row(self, row_number: Optional[int]) -> bool: def set_next_row(self, row_number: Optional[int]) -> None:
return self.source_model.set_next_row(row_number) return self.source_model.set_next_row(row_number)
def sort_by_artist(self, row_numbers: List[int]) -> None: def sort_by_artist(self, row_numbers: List[int]) -> None:

View File

@ -35,7 +35,7 @@ from PyQt6.QtWidgets import (
# Third party imports # Third party imports
# App imports # App imports
from classes import Col, MusicMusterSignals from classes import Col, MusicMusterSignals, track_sequence
from config import Config from config import Config
from dialogs import TrackSelectDialog from dialogs import TrackSelectDialog
from helpers import ( from helpers import (
@ -47,7 +47,6 @@ from helpers import (
from log import log from log import log
from models import db, Settings from models import db, Settings
from playlistmodel import PlaylistModel, PlaylistProxyModel from playlistmodel import PlaylistModel, PlaylistProxyModel
from trackmanager import track_sequence
if TYPE_CHECKING: if TYPE_CHECKING:
from musicmuster import Window from musicmuster import Window
@ -119,13 +118,9 @@ class EscapeDelegate(QStyledItemDelegate):
# Close editor if no changes have been made # Close editor if no changes have been made
data_modified = False data_modified = False
if isinstance(self.editor, QPlainTextEdit): if isinstance(self.editor, QPlainTextEdit):
data_modified = ( data_modified = self.original_model_data == self.editor.toPlainText()
self.original_model_data == self.editor.toPlainText()
)
elif isinstance(self.editor, QDoubleSpinBox): elif isinstance(self.editor, QDoubleSpinBox):
data_modified = ( data_modified = self.original_model_data == int(self.editor.value()) * 1000
self.original_model_data == int(self.editor.value()) * 1000
)
if data_modified: if data_modified:
self.closeEditor.emit(editor) self.closeEditor.emit(editor)
return True return True
@ -430,18 +425,12 @@ class PlaylistTab(QTableView):
header_row = proxy_model.is_header_row(model_row_number) header_row = proxy_model.is_header_row(model_row_number)
track_row = not header_row track_row = not header_row
if track_sequence.current: current_row = model_row_number == track_sequence.now.plr_rownum
this_is_current_row = model_row_number == track_sequence.current.row_number next_row = model_row_number == track_sequence.next.plr_rownum
else:
this_is_current_row = False
if track_sequence.next:
this_is_next_row = model_row_number == track_sequence.next.row_number
else:
this_is_next_row = False
track_path = self.source_model.get_row_info(model_row_number).path track_path = self.source_model.get_row_info(model_row_number).path
# Open/import in/from Audacity # Open/import in/from Audacity
if track_row and not this_is_current_row: if track_row and not current_row:
if track_path == self.musicmuster.audacity_file_path: if track_path == self.musicmuster.audacity_file_path:
# This track was opened in Audacity # This track was opened in Audacity
self._add_context_menu( self._add_context_menu(
@ -458,7 +447,7 @@ class PlaylistTab(QTableView):
) )
# Rescan # Rescan
if track_row and not this_is_current_row: if track_row and not current_row:
self._add_context_menu( self._add_context_menu(
"Rescan track", lambda: self._rescan(model_row_number) "Rescan track", lambda: self._rescan(model_row_number)
) )
@ -467,11 +456,11 @@ class PlaylistTab(QTableView):
self.menu.addSeparator() self.menu.addSeparator()
# Delete row # Delete row
if not this_is_current_row and not this_is_next_row: if not current_row and not next_row:
self._add_context_menu("Delete row", lambda: self._delete_rows()) self._add_context_menu("Delete row", lambda: self._delete_rows())
# Remove track from row # Remove track from row
if track_row and not this_is_current_row and not this_is_next_row: if track_row and not current_row and not next_row:
self._add_context_menu( self._add_context_menu(
"Remove track from row", "Remove track from row",
lambda: proxy_model.remove_track(model_row_number), lambda: proxy_model.remove_track(model_row_number),
@ -492,7 +481,7 @@ class PlaylistTab(QTableView):
) )
# Unmark as next # Unmark as next
if this_is_next_row: if next_row:
self._add_context_menu( self._add_context_menu(
"Unmark as next track", lambda: self._unmark_as_next() "Unmark as next track", lambda: self._unmark_as_next()
) )
@ -631,23 +620,21 @@ class PlaylistTab(QTableView):
self.source_model.delete_rows(self.selected_model_row_numbers()) self.source_model.delete_rows(self.selected_model_row_numbers())
self.clear_selection() self.clear_selection()
def get_selected_row_and_track_id(self) -> Optional[tuple[int, int]]: def get_selected_row_track_id(self) -> Optional[int]:
""" """
Return the (row_number, track_id) of the selected row. If no row selected or selected Return the track_id of the selected row. If no row selected or selected
row does not have a track, return None. row does not have a track, return None.
""" """
row_number = self.source_model_selected_row_number() log.debug("get_selected_row_track_id() called")
if row_number is None:
result = None
else:
track_id = self.source_model.get_row_track_id(row_number)
if not track_id:
result = None
else:
result = (row_number, track_id)
log.debug(f"get_selected_row_and_track_id() returned: {result=}") model_row_number = self.source_model_selected_row_number()
if model_row_number is None:
result = None
else:
result = self.source_model.get_row_track_id(model_row_number)
log.debug(f"get_selected_row_track_id() returned: {result=}")
return result return result

View File

@ -1,668 +0,0 @@
# Standard library imports
from __future__ import annotations
import datetime as dt
import threading
from time import sleep
from typing import Optional
# Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
import vlc # type: ignore
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QObject,
QRunnable,
QThread,
QThreadPool,
)
# App imports
from classes import MusicMusterSignals
from config import Config
from log import log
from models import db, PlaylistRows, Tracks
from helpers import (
file_is_unreadable,
get_audio_segment,
)
lock = threading.Lock()
class _AddFadeCurve(QObject):
"""
Initialising a fade curve introduces a noticeable delay so carry out in
a thread.
"""
finished = pyqtSignal()
def __init__(
self,
track_manager: _TrackManager,
track_path: str,
track_fade_at: int,
track_silence_at: int,
):
super().__init__()
self.track_manager = track_manager
self.track_path = track_path
self.track_fade_at = track_fade_at
self.track_silence_at = track_silence_at
def run(self):
"""
Create fade curve and add to PlaylistTrack object
"""
fc = _FadeCurve(self.track_path, self.track_fade_at, self.track_silence_at)
if not fc:
log.error(f"Failed to create FadeCurve for {self.track_path=}")
else:
self.track_manager.fade_graph = fc
self.finished.emit()
class _FadeCurve:
GraphWidget = None
def __init__(
self, track_path: str, track_fade_at: int, track_silence_at: int
) -> None:
"""
Set up fade graph array
"""
audio = get_audio_segment(track_path)
if not audio:
log.error(f"FadeCurve: could not get audio for {track_path=}")
return None
# Start point of curve is Config.FADE_CURVE_MS_BEFORE_FADE
# milliseconds before fade starts to silence
self.start_ms: int = max(
0, track_fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.end_ms: int = track_silence_at
self.audio_segment = audio[self.start_ms : self.end_ms]
self.graph_array = np.array(self.audio_segment.get_array_of_samples())
# Calculate the factor to map milliseconds of track to array
self.ms_to_array_factor = len(self.graph_array) / (self.end_ms - self.start_ms)
self.region = None
def clear(self) -> None:
"""Clear the current graph"""
if self.GraphWidget:
self.GraphWidget.clear()
def plot(self):
self.curve = self.GraphWidget.plot(self.graph_array)
self.curve.setPen(Config.FADE_CURVE_FOREGROUND)
def tick(self, play_time) -> None:
"""Update volume fade curve"""
if not self.GraphWidget:
return
ms_of_graph = play_time - self.start_ms
if ms_of_graph < 0:
return
if self.region is None:
# Create the region now that we're into fade
self.region = pg.LinearRegionItem([0, 0], bounds=[0, len(self.graph_array)])
self.GraphWidget.addItem(self.region)
# Update region position
self.region.setRegion([0, ms_of_graph * self.ms_to_array_factor])
class _FadeTrack(QRunnable):
def __init__(self, player: vlc.MediaPlayer, fade_seconds) -> None:
super().__init__()
self.player = player
self.fade_seconds = fade_seconds
def run(self) -> None:
"""
Implementation of fading the player
"""
if not self.player:
return
# Reduce volume logarithmically
total_steps = self.fade_seconds * Config.FADEOUT_STEPS_PER_SECOND
db_reduction_per_step = Config.FADEOUT_DB / total_steps
reduction_factor_per_step = pow(10, (db_reduction_per_step / 20))
volume = self.player.audio_get_volume()
for i in range(1, total_steps + 1):
self.player.audio_set_volume(
int(volume * pow(reduction_factor_per_step, i))
)
sleep(1 / Config.FADEOUT_STEPS_PER_SECOND)
self.player.stop()
log.debug(f"Releasing player {self.player=}")
self.player.release()
class _Music:
"""
Manage the playing of music tracks
"""
def __init__(self, name) -> None:
self.VLC = vlc.Instance()
self.VLC.set_user_agent(name, name)
self.player = None
self.name = name
self.max_volume = Config.VLC_VOLUME_DEFAULT
self.start_dt: Optional[dt.datetime] = None
def adjust_by_ms(self, ms: int) -> None:
"""Move player position by ms milliseconds"""
if not self.player:
return
elapsed_ms = self.get_playtime()
position = self.get_position()
if not position:
position = 0
new_position = max(0, position + ((position * ms) / elapsed_ms))
self.set_position(new_position)
# Adjus start time so elapsed time calculations are correct
if new_position == 0:
self.start_dt = dt.datetime.now()
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.
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
if fade_seconds <= 0:
self.stop()
return
# Take a copy of current player to allow another track to be
# started without interfering here
with lock:
p = self.player
self.player = None
pool = QThreadPool.globalInstance()
fader = _FadeTrack(p, fade_seconds=fade_seconds)
pool.start(fader)
self.start_dt = None
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.start_dt is None:
return 0
now = dt.datetime.now()
elapsed_seconds = (now - self.start_dt).total_seconds()
return int(elapsed_seconds * 1000)
def get_position(self) -> Optional[float]:
"""Return current position"""
if not self.player:
return None
return self.player.get_position()
def is_playing(self) -> bool:
"""
Return True if we're playing
"""
if not self.player:
return False
# There is a discrete time between starting playing a track and
# 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)
)
def play(
self,
path: str,
start_time: dt.datetime,
position: Optional[float] = None,
) -> None:
"""
Start playing the track at path.
Log and return if path not found.
start_time ensures our version and our caller's version of
the start time is the same
"""
log.debug(f"Music[{self.name}].play({path=}, {position=}")
if file_is_unreadable(path):
log.error(f"play({path}): path not readable")
return None
media = self.VLC.media_new_path(path)
self.player = media.player_new_from_media()
if self.player:
_ = self.player.play()
self.set_volume(self.max_volume)
if position:
self.player.set_position(position)
self.start_dt = start_time
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
# It has been known for the volume to need correcting more
# than once in the first 200mS.
for _ in range(3):
if self.player:
volume = self.player.audio_get_volume()
if volume < Config.VLC_VOLUME_DEFAULT:
self.set_volume(Config.VLC_VOLUME_DEFAULT)
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"""
if not self.player:
return
if set_default:
self.max_volume = volume
if volume is None:
volume = Config.VLC_VOLUME_DEFAULT
self.player.audio_set_volume(volume)
# Ensure volume correct
# For as-yet unknown reasons. sometimes the volume gets
# reset to zero within 200mS or so of starting play. This
# only happened since moving to Debian 12, which uses
# Pipewire for sound (which may be irrelevant).
for _ in range(3):
current_volume = self.player.audio_get_volume()
if current_volume < volume:
self.player.audio_set_volume(volume)
log.debug(f"Reset from {volume=}")
sleep(0.1)
class _TrackManager:
"""
Object to manage active playlist tracks,
typically the previous, current and next track.
"""
def __init__(
self,
session: db.Session,
player_name: str,
track_id: int,
row_number: int,
preview_player: bool = False,
) -> None:
"""
Initialises data structure.
Define a player.
Raise ValueError if no track in passed plr.
"""
track = session.get(Tracks, track_id)
if not track:
raise ValueError(f"_TrackPlayer: unable to retreived {track_id=}")
self.player_name = player_name
self.row_number = row_number
self.preview_player = preview_player
# Check file readable
if file_is_unreadable(track.path):
raise ValueError(f"_TrackManager.__init__: {track.path=} unreadable")
self.artist = track.artist
self.bitrate = track.bitrate
self.duration = track.duration
self.fade_at = track.fade_at
self.intro = track.intro
self.path = track.path
self.silence_at = track.silence_at
self.start_gap = track.start_gap
self.title = track.title
self.track_id = track.id
self.end_time: Optional[dt.datetime] = None
self.fade_graph: Optional[_FadeCurve] = None
self.fade_graph_start_updates: Optional[dt.datetime] = None
self.resume_marker: Optional[float]
self.start_time: Optional[dt.datetime] = None
self.end_of_track_signalled: bool = False
self.signals = MusicMusterSignals()
# Initialise player
self.player = _Music(name=player_name)
# Initialise and add FadeCurve in a thread as it's slow
if not self.preview_player:
self.fadecurve_thread = QThread()
self.worker = _AddFadeCurve(
self,
track_path=track.path,
track_fade_at=track.fade_at,
track_silence_at=track.silence_at,
)
self.worker.moveToThread(self.fadecurve_thread)
self.fadecurve_thread.started.connect(self.worker.run)
self.worker.finished.connect(self.fadecurve_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
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.preview_player:
return
if self.start_time is None:
return
if self.end_of_track_signalled:
return
if not self.player.is_playing():
self.start_time = None
if self.fade_graph:
self.fade_graph.clear()
self.signal_end_of_track()
self.end_of_track_signalled = True
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.resume_marker = self.player.get_position()
self.player.fade(fade_seconds)
self.signal_end_of_track()
def is_playing(self) -> bool:
"""
Return True if we're currently playing else False
"""
if self.start_time is None:
return False
return self.player.is_playing()
def move_back(self, ms: int = Config.PREVIEW_BACK_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms * -1)
def move_forward(self, ms: int = Config.PREVIEW_ADVANCE_MS) -> None:
"""
Rewind player by ms milliseconds
"""
self.player.adjust_by_ms(ms)
def move_to_intro_end(self, buffer: int = Config.PREVIEW_END_BUFFER_MS) -> None:
"""
Move play position to 'buffer' milliseconds before end of intro.
If no intro defined, do nothing.
"""
if self.intro is None:
return
new_position = max(0, self.intro - Config.PREVIEW_END_BUFFER_MS)
self.player.adjust_by_ms(new_position - self.time_playing())
def play(self, position: Optional[float] = None) -> None:
"""Play track"""
now = dt.datetime.now()
self.start_time = now
self.player.play(self.path, start_time=now, position=position)
self.end_time = self.start_time + dt.timedelta(milliseconds=self.duration)
# Calculate time fade_graph should start updating
if self.fade_at:
update_graph_at_ms = max(
0, self.fade_at - Config.FADE_CURVE_MS_BEFORE_FADE - 1
)
self.fade_graph_start_updates = now + dt.timedelta(
milliseconds=update_graph_at_ms
)
def restart(self) -> None:
"""
Restart player
"""
self.player.adjust_by_ms(self.time_playing() * -1)
def signal_end_of_track(self) -> None:
"""
Send end of track signal unless we are a preview player
"""
if not self.preview_player:
self.signals.track_ended_signal.emit()
def stop(self, fade_seconds: int = 0) -> None:
"""
Stop this track playing
"""
self.resume_marker = self.player.get_position()
self.fade(fade_seconds)
# Reset fade graph
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_remaining_intro(self) -> int:
"""
Return milliseconds of intro remaining. Return 0 if no intro time in track
record or if intro has finished.
"""
if not self.intro:
return 0
return max(0, self.intro - self.time_playing())
def time_to_fade(self) -> int:
"""
Return milliseconds until fade time. Return zero if we're not 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()
def update_fade_graph(self) -> None:
"""
Update fade graph
"""
if (
not self.is_playing()
or not self.fade_graph_start_updates
or not self.fade_graph
):
return
now = dt.datetime.now()
if self.fade_graph_start_updates > now:
return
self.fade_graph.tick(self.time_playing())
class MainTrackManager(_TrackManager):
"""
Manage playing tracks from the playlist with associated data
"""
def __init__(self, session: db.Session, plr_id: int) -> None:
"""
Set up manager for playlist tracks
"""
# 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
super().__init__(
session=session,
player_name=Config.VLC_MAIN_PLAYER_NAME,
track_id=self.track_id,
row_number=plr.plr_rownum,
)
# Save non-track plr info
self.plr_id: int = plr.id
self.playlist_id: int = plr.playlist_id
def __repr__(self) -> str:
return (
f"<MainTrackManager(plr_id={self.plr_id}, playlist_id={self.playlist_id}, "
f"row_number={self.row_number}>"
)
class PreviewTrackManager(_TrackManager):
"""
Manage previewing tracks
"""
def __init__(self, session: db.Session, track_id: int, row_number: int) -> None:
super().__init__(
session=session,
player_name=Config.VLC_PREVIEW_PLAYER_NAME,
track_id=track_id,
row_number=row_number,
preview_player=True,
)
def __repr__(self) -> str:
return f"<PreviewTrackManager(track_id={self.track_id}>"
class TrackSequence:
next: Optional[MainTrackManager] = None
current: Optional[MainTrackManager] = None
previous: Optional[MainTrackManager] = None
track_sequence = TrackSequence()

View File

@ -15,11 +15,7 @@ class Ui_MainWindow(object):
MainWindow.resize(1280, 857) MainWindow.resize(1280, 857)
MainWindow.setMinimumSize(QtCore.QSize(1280, 0)) MainWindow.setMinimumSize(QtCore.QSize(1280, 0))
icon = QtGui.QIcon() icon = QtGui.QIcon()
icon.addPixmap( icon.addPixmap(QtGui.QPixmap(":/icons/musicmuster"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/musicmuster"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
MainWindow.setWindowIcon(icon) MainWindow.setWindowIcon(icon)
MainWindow.setStyleSheet("") MainWindow.setStyleSheet("")
self.centralwidget = QtWidgets.QWidget(parent=MainWindow) self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
@ -31,62 +27,39 @@ class Ui_MainWindow(object):
self.verticalLayout_3 = QtWidgets.QVBoxLayout() self.verticalLayout_3 = QtWidgets.QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3") self.verticalLayout_3.setObjectName("verticalLayout_3")
self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.previous_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth( sizePolicy.setHeightForWidth(self.previous_track_2.sizePolicy().hasHeightForWidth())
self.previous_track_2.sizePolicy().hasHeightForWidth()
)
self.previous_track_2.setSizePolicy(sizePolicy) self.previous_track_2.setSizePolicy(sizePolicy)
self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215)) self.previous_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.previous_track_2.setFont(font) self.previous_track_2.setFont(font)
self.previous_track_2.setStyleSheet( self.previous_track_2.setStyleSheet("background-color: #f8d7da;\n"
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);" "border: 1px solid rgb(85, 87, 83);")
) self.previous_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.previous_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.previous_track_2.setObjectName("previous_track_2") self.previous_track_2.setObjectName("previous_track_2")
self.verticalLayout_3.addWidget(self.previous_track_2) self.verticalLayout_3.addWidget(self.previous_track_2)
self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.current_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth( sizePolicy.setHeightForWidth(self.current_track_2.sizePolicy().hasHeightForWidth())
self.current_track_2.sizePolicy().hasHeightForWidth()
)
self.current_track_2.setSizePolicy(sizePolicy) self.current_track_2.setSizePolicy(sizePolicy)
self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215)) self.current_track_2.setMaximumSize(QtCore.QSize(230, 16777215))
font = QtGui.QFont() font = QtGui.QFont()
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.current_track_2.setFont(font) self.current_track_2.setFont(font)
self.current_track_2.setStyleSheet( self.current_track_2.setStyleSheet("background-color: #d4edda;\n"
"background-color: #d4edda;\n" "border: 1px solid rgb(85, 87, 83);" "border: 1px solid rgb(85, 87, 83);")
) self.current_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.current_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.current_track_2.setObjectName("current_track_2") self.current_track_2.setObjectName("current_track_2")
self.verticalLayout_3.addWidget(self.current_track_2) self.verticalLayout_3.addWidget(self.current_track_2)
self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget) self.next_track_2 = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.next_track_2.sizePolicy().hasHeightForWidth())
@ -96,29 +69,19 @@ class Ui_MainWindow(object):
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.next_track_2.setFont(font) self.next_track_2.setFont(font)
self.next_track_2.setStyleSheet( self.next_track_2.setStyleSheet("background-color: #fff3cd;\n"
"background-color: #fff3cd;\n" "border: 1px solid rgb(85, 87, 83);" "border: 1px solid rgb(85, 87, 83);")
) self.next_track_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter)
self.next_track_2.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignTrailing
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.next_track_2.setObjectName("next_track_2") self.next_track_2.setObjectName("next_track_2")
self.verticalLayout_3.addWidget(self.next_track_2) self.verticalLayout_3.addWidget(self.next_track_2)
self.horizontalLayout_3.addLayout(self.verticalLayout_3) self.horizontalLayout_3.addLayout(self.verticalLayout_3)
self.verticalLayout = QtWidgets.QVBoxLayout() self.verticalLayout = QtWidgets.QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout") self.verticalLayout.setObjectName("verticalLayout")
self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget) self.hdrPreviousTrack = QtWidgets.QLabel(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth( sizePolicy.setHeightForWidth(self.hdrPreviousTrack.sizePolicy().hasHeightForWidth())
self.hdrPreviousTrack.sizePolicy().hasHeightForWidth()
)
self.hdrPreviousTrack.setSizePolicy(sizePolicy) self.hdrPreviousTrack.setSizePolicy(sizePolicy)
self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0)) self.hdrPreviousTrack.setMinimumSize(QtCore.QSize(0, 0))
self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215)) self.hdrPreviousTrack.setMaximumSize(QtCore.QSize(16777215, 16777215))
@ -126,43 +89,32 @@ class Ui_MainWindow(object):
font.setFamily("Sans") font.setFamily("Sans")
font.setPointSize(20) font.setPointSize(20)
self.hdrPreviousTrack.setFont(font) self.hdrPreviousTrack.setFont(font)
self.hdrPreviousTrack.setStyleSheet( self.hdrPreviousTrack.setStyleSheet("background-color: #f8d7da;\n"
"background-color: #f8d7da;\n" "border: 1px solid rgb(85, 87, 83);" "border: 1px solid rgb(85, 87, 83);")
)
self.hdrPreviousTrack.setText("") self.hdrPreviousTrack.setText("")
self.hdrPreviousTrack.setWordWrap(False) self.hdrPreviousTrack.setWordWrap(False)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack") self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack) self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget) self.hdrCurrentTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth( sizePolicy.setHeightForWidth(self.hdrCurrentTrack.sizePolicy().hasHeightForWidth())
self.hdrCurrentTrack.sizePolicy().hasHeightForWidth()
)
self.hdrCurrentTrack.setSizePolicy(sizePolicy) self.hdrCurrentTrack.setSizePolicy(sizePolicy)
font = QtGui.QFont() font = QtGui.QFont()
font.setPointSize(20) font.setPointSize(20)
self.hdrCurrentTrack.setFont(font) self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet( self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n" "text-align: left;\n"
"padding-left: 8px;\n" "padding-left: 8px;\n"
"" "")
)
self.hdrCurrentTrack.setText("") self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setFlat(True) self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack") self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack) self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget) self.hdrNextTrack = QtWidgets.QPushButton(parent=self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth()) sizePolicy.setHeightForWidth(self.hdrNextTrack.sizePolicy().hasHeightForWidth())
@ -170,12 +122,10 @@ class Ui_MainWindow(object):
font = QtGui.QFont() font = QtGui.QFont()
font.setPointSize(20) font.setPointSize(20)
self.hdrNextTrack.setFont(font) self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet( self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);\n" "border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;\n" "text-align: left;\n"
"padding-left: 8px;" "padding-left: 8px;")
)
self.hdrNextTrack.setText("") self.hdrNextTrack.setText("")
self.hdrNextTrack.setFlat(True) self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack") self.hdrNextTrack.setObjectName("hdrNextTrack")
@ -222,12 +172,7 @@ class Ui_MainWindow(object):
self.cartsWidget.setObjectName("cartsWidget") self.cartsWidget.setObjectName("cartsWidget")
self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget) self.horizontalLayout_Carts = QtWidgets.QHBoxLayout(self.cartsWidget)
self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts") self.horizontalLayout_Carts.setObjectName("horizontalLayout_Carts")
spacerItem = QtWidgets.QSpacerItem( spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
40,
20,
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Minimum,
)
self.horizontalLayout_Carts.addItem(spacerItem) self.horizontalLayout_Carts.addItem(spacerItem)
self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1) self.gridLayout_4.addWidget(self.cartsWidget, 2, 0, 1, 1)
self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget) self.frame_6 = QtWidgets.QFrame(parent=self.centralwidget)
@ -272,11 +217,7 @@ class Ui_MainWindow(object):
self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame) self.btnPreview = QtWidgets.QPushButton(parent=self.FadeStopInfoFrame)
self.btnPreview.setMinimumSize(QtCore.QSize(132, 41)) self.btnPreview.setMinimumSize(QtCore.QSize(132, 41))
icon1 = QtGui.QIcon() icon1 = QtGui.QIcon()
icon1.addPixmap( icon1.addPixmap(QtGui.QPixmap(":/icons/headphones"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/headphones"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnPreview.setIcon(icon1) self.btnPreview.setIcon(icon1)
self.btnPreview.setIconSize(QtCore.QSize(30, 30)) self.btnPreview.setIconSize(QtCore.QSize(30, 30))
self.btnPreview.setCheckable(True) self.btnPreview.setCheckable(True)
@ -298,16 +239,8 @@ class Ui_MainWindow(object):
self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23)) self.btnPreviewArm.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewArm.setText("") self.btnPreviewArm.setText("")
icon2 = QtGui.QIcon() icon2 = QtGui.QIcon()
icon2.addPixmap( icon2.addPixmap(QtGui.QPixmap(":/icons/record-button.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/record-button.png"), icon2.addPixmap(QtGui.QPixmap(":/icons/record-red-button.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.On)
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
icon2.addPixmap(
QtGui.QPixmap(":/icons/record-red-button.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
self.btnPreviewArm.setIcon(icon2) self.btnPreviewArm.setIcon(icon2)
self.btnPreviewArm.setCheckable(True) self.btnPreviewArm.setCheckable(True)
self.btnPreviewArm.setObjectName("btnPreviewArm") self.btnPreviewArm.setObjectName("btnPreviewArm")
@ -328,16 +261,8 @@ class Ui_MainWindow(object):
self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23)) self.btnPreviewMark.setMaximumSize(QtCore.QSize(44, 23))
self.btnPreviewMark.setText("") self.btnPreviewMark.setText("")
icon3 = QtGui.QIcon() icon3 = QtGui.QIcon()
icon3.addPixmap( icon3.addPixmap(QtGui.QPixmap(":/icons/star.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.On)
QtGui.QPixmap(":/icons/star.png"), icon3.addPixmap(QtGui.QPixmap(":/icons/star_empty.png"), QtGui.QIcon.Mode.Disabled, QtGui.QIcon.State.Off)
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.On,
)
icon3.addPixmap(
QtGui.QPixmap(":/icons/star_empty.png"),
QtGui.QIcon.Mode.Disabled,
QtGui.QIcon.State.Off,
)
self.btnPreviewMark.setIcon(icon3) self.btnPreviewMark.setIcon(icon3)
self.btnPreviewMark.setObjectName("btnPreviewMark") self.btnPreviewMark.setObjectName("btnPreviewMark")
self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls) self.btnPreviewFwd = QtWidgets.QPushButton(parent=self.groupBoxIntroControls)
@ -438,15 +363,10 @@ class Ui_MainWindow(object):
self.verticalLayout_7.addWidget(self.label_silent_timer) self.verticalLayout_7.addWidget(self.label_silent_timer)
self.horizontalLayout.addWidget(self.frame_silent) self.horizontalLayout.addWidget(self.frame_silent)
self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame) self.widgetFadeVolume = PlotWidget(parent=self.InfoFooterFrame)
sizePolicy = QtWidgets.QSizePolicy( sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred)
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Preferred,
)
sizePolicy.setHorizontalStretch(1) sizePolicy.setHorizontalStretch(1)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth( sizePolicy.setHeightForWidth(self.widgetFadeVolume.sizePolicy().hasHeightForWidth())
self.widgetFadeVolume.sizePolicy().hasHeightForWidth()
)
self.widgetFadeVolume.setSizePolicy(sizePolicy) self.widgetFadeVolume.setSizePolicy(sizePolicy)
self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0)) self.widgetFadeVolume.setMinimumSize(QtCore.QSize(0, 0))
self.widgetFadeVolume.setObjectName("widgetFadeVolume") self.widgetFadeVolume.setObjectName("widgetFadeVolume")
@ -463,11 +383,7 @@ class Ui_MainWindow(object):
self.btnFade.setMinimumSize(QtCore.QSize(132, 32)) self.btnFade.setMinimumSize(QtCore.QSize(132, 32))
self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215)) self.btnFade.setMaximumSize(QtCore.QSize(164, 16777215))
icon4 = QtGui.QIcon() icon4 = QtGui.QIcon()
icon4.addPixmap( icon4.addPixmap(QtGui.QPixmap(":/icons/fade"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/fade"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnFade.setIcon(icon4) self.btnFade.setIcon(icon4)
self.btnFade.setIconSize(QtCore.QSize(30, 30)) self.btnFade.setIconSize(QtCore.QSize(30, 30))
self.btnFade.setObjectName("btnFade") self.btnFade.setObjectName("btnFade")
@ -475,11 +391,7 @@ class Ui_MainWindow(object):
self.btnStop = QtWidgets.QPushButton(parent=self.frame) self.btnStop = QtWidgets.QPushButton(parent=self.frame)
self.btnStop.setMinimumSize(QtCore.QSize(0, 36)) self.btnStop.setMinimumSize(QtCore.QSize(0, 36))
icon5 = QtGui.QIcon() icon5 = QtGui.QIcon()
icon5.addPixmap( icon5.addPixmap(QtGui.QPixmap(":/icons/stopsign"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/stopsign"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.btnStop.setIcon(icon5) self.btnStop.setIcon(icon5)
self.btnStop.setObjectName("btnStop") self.btnStop.setObjectName("btnStop")
self.verticalLayout_5.addWidget(self.btnStop) self.verticalLayout_5.addWidget(self.btnStop)
@ -505,71 +417,39 @@ class Ui_MainWindow(object):
MainWindow.setStatusBar(self.statusbar) MainWindow.setStatusBar(self.statusbar)
self.actionPlay_next = QtGui.QAction(parent=MainWindow) self.actionPlay_next = QtGui.QAction(parent=MainWindow)
icon6 = QtGui.QIcon() icon6 = QtGui.QIcon()
icon6.addPixmap( icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-play.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionPlay_next.setIcon(icon6) self.actionPlay_next.setIcon(icon6)
self.actionPlay_next.setObjectName("actionPlay_next") self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkipToNext = QtGui.QAction(parent=MainWindow) self.actionSkipToNext = QtGui.QAction(parent=MainWindow)
icon7 = QtGui.QIcon() icon7 = QtGui.QIcon()
icon7.addPixmap( icon7.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/next"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionSkipToNext.setIcon(icon7) self.actionSkipToNext.setIcon(icon7)
self.actionSkipToNext.setObjectName("actionSkipToNext") self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtGui.QAction(parent=MainWindow) self.actionInsertTrack = QtGui.QAction(parent=MainWindow)
icon8 = QtGui.QIcon() icon8 = QtGui.QIcon()
icon8.addPixmap( icon8.addPixmap(QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_search_database.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionInsertTrack.setIcon(icon8) self.actionInsertTrack.setIcon(icon8)
self.actionInsertTrack.setObjectName("actionInsertTrack") self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtGui.QAction(parent=MainWindow) self.actionAdd_file = QtGui.QAction(parent=MainWindow)
icon9 = QtGui.QIcon() icon9 = QtGui.QIcon()
icon9.addPixmap( icon9.addPixmap(QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(
"app/ui/../../../../../../.designer/backup/icon_open_file.png"
),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionAdd_file.setIcon(icon9) self.actionAdd_file.setIcon(icon9)
self.actionAdd_file.setObjectName("actionAdd_file") self.actionAdd_file.setObjectName("actionAdd_file")
self.actionFade = QtGui.QAction(parent=MainWindow) self.actionFade = QtGui.QAction(parent=MainWindow)
icon10 = QtGui.QIcon() icon10 = QtGui.QIcon()
icon10.addPixmap( icon10.addPixmap(QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-fade.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap("app/ui/../../../../../../.designer/backup/icon-fade.png"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionFade.setIcon(icon10) self.actionFade.setIcon(icon10)
self.actionFade.setObjectName("actionFade") self.actionFade.setObjectName("actionFade")
self.actionStop = QtGui.QAction(parent=MainWindow) self.actionStop = QtGui.QAction(parent=MainWindow)
icon11 = QtGui.QIcon() icon11 = QtGui.QIcon()
icon11.addPixmap( icon11.addPixmap(QtGui.QPixmap(":/icons/stop"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/stop"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.actionStop.setIcon(icon11) self.actionStop.setIcon(icon11)
self.actionStop.setObjectName("actionStop") self.actionStop.setObjectName("actionStop")
self.action_Clear_selection = QtGui.QAction(parent=MainWindow) self.action_Clear_selection = QtGui.QAction(parent=MainWindow)
self.action_Clear_selection.setObjectName("action_Clear_selection") self.action_Clear_selection.setObjectName("action_Clear_selection")
self.action_Resume_previous = QtGui.QAction(parent=MainWindow) self.action_Resume_previous = QtGui.QAction(parent=MainWindow)
icon12 = QtGui.QIcon() icon12 = QtGui.QIcon()
icon12.addPixmap( icon12.addPixmap(QtGui.QPixmap(":/icons/previous"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
QtGui.QPixmap(":/icons/previous"),
QtGui.QIcon.Mode.Normal,
QtGui.QIcon.State.Off,
)
self.action_Resume_previous.setIcon(icon12) self.action_Resume_previous.setIcon(icon12)
self.action_Resume_previous.setObjectName("action_Resume_previous") self.action_Resume_previous.setObjectName("action_Resume_previous")
self.actionE_xit = QtGui.QAction(parent=MainWindow) self.actionE_xit = QtGui.QAction(parent=MainWindow)
@ -616,9 +496,7 @@ class Ui_MainWindow(object):
self.actionImport = QtGui.QAction(parent=MainWindow) self.actionImport = QtGui.QAction(parent=MainWindow)
self.actionImport.setObjectName("actionImport") self.actionImport.setObjectName("actionImport")
self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow) self.actionDownload_CSV_of_played_tracks = QtGui.QAction(parent=MainWindow)
self.actionDownload_CSV_of_played_tracks.setObjectName( self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
"actionDownload_CSV_of_played_tracks"
)
self.actionSearch = QtGui.QAction(parent=MainWindow) self.actionSearch = QtGui.QAction(parent=MainWindow)
self.actionSearch.setObjectName("actionSearch") self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow) self.actionInsertSectionHeader = QtGui.QAction(parent=MainWindow)
@ -646,13 +524,9 @@ class Ui_MainWindow(object):
self.actionResume = QtGui.QAction(parent=MainWindow) self.actionResume = QtGui.QAction(parent=MainWindow)
self.actionResume.setObjectName("actionResume") self.actionResume.setObjectName("actionResume")
self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow) self.actionSearch_title_in_Wikipedia = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Wikipedia.setObjectName( self.actionSearch_title_in_Wikipedia.setObjectName("actionSearch_title_in_Wikipedia")
"actionSearch_title_in_Wikipedia"
)
self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow) self.actionSearch_title_in_Songfacts = QtGui.QAction(parent=MainWindow)
self.actionSearch_title_in_Songfacts.setObjectName( self.actionSearch_title_in_Songfacts.setObjectName("actionSearch_title_in_Songfacts")
"actionSearch_title_in_Songfacts"
)
self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow) self.actionSelect_duplicate_rows = QtGui.QAction(parent=MainWindow)
self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows") self.actionSelect_duplicate_rows.setObjectName("actionSelect_duplicate_rows")
self.actionReplace_files = QtGui.QAction(parent=MainWindow) self.actionReplace_files = QtGui.QAction(parent=MainWindow)
@ -749,58 +623,38 @@ class Ui_MainWindow(object):
self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z")) self.actionFade.setShortcut(_translate("MainWindow", "Ctrl+Z"))
self.actionStop.setText(_translate("MainWindow", "S&top")) self.actionStop.setText(_translate("MainWindow", "S&top"))
self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S")) self.actionStop.setShortcut(_translate("MainWindow", "Ctrl+Alt+S"))
self.action_Clear_selection.setText( self.action_Clear_selection.setText(_translate("MainWindow", "Clear &selection"))
_translate("MainWindow", "Clear &selection")
)
self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc")) self.action_Clear_selection.setShortcut(_translate("MainWindow", "Esc"))
self.action_Resume_previous.setText( self.action_Resume_previous.setText(_translate("MainWindow", "&Resume previous"))
_translate("MainWindow", "&Resume previous")
)
self.actionE_xit.setText(_translate("MainWindow", "E&xit")) self.actionE_xit.setText(_translate("MainWindow", "E&xit"))
self.actionTest.setText(_translate("MainWindow", "&Test")) self.actionTest.setText(_translate("MainWindow", "&Test"))
self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen...")) self.actionOpenPlaylist.setText(_translate("MainWindow", "O&pen..."))
self.actionNewPlaylist.setText(_translate("MainWindow", "&New...")) self.actionNewPlaylist.setText(_translate("MainWindow", "&New..."))
self.actionTestFunction.setText(_translate("MainWindow", "&Test function")) self.actionTestFunction.setText(_translate("MainWindow", "&Test function"))
self.actionSkipToFade.setText( self.actionSkipToFade.setText(_translate("MainWindow", "&Skip to start of fade"))
_translate("MainWindow", "&Skip to start of fade")
)
self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track")) self.actionSkipToEnd.setText(_translate("MainWindow", "Skip to &end of track"))
self.actionClosePlaylist.setText(_translate("MainWindow", "&Close")) self.actionClosePlaylist.setText(_translate("MainWindow", "&Close"))
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename...")) self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te...")) self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText( self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to..."))
_translate("MainWindow", "Mo&ve selected tracks to...")
)
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport...")) self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next")) self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N")) self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText( self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track"))
_translate("MainWindow", "Select next track")
)
self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J")) self.actionSelect_next_track.setShortcut(_translate("MainWindow", "J"))
self.actionSelect_previous_track.setText( self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track"))
_translate("MainWindow", "Select previous track")
)
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K")) self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText( self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks"))
_translate("MainWindow", "Select played tracks") self.actionMoveUnplayed.setText(_translate("MainWindow", "Move &unplayed tracks to..."))
)
self.actionMoveUnplayed.setText(
_translate("MainWindow", "Move &unplayed tracks to...")
)
self.actionAdd_note.setText(_translate("MainWindow", "Add note...")) self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T")) self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls")) self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import track...")) self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I")) self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText( self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
_translate("MainWindow", "Download CSV of played tracks...")
)
self.actionSearch.setText(_translate("MainWindow", "Search...")) self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/")) self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText( self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert &section header..."))
_translate("MainWindow", "Insert &section header...")
)
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H")) self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track")) self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next")) self.actionFind_next.setText(_translate("MainWindow", "Find next"))
@ -808,12 +662,8 @@ class Ui_MainWindow(object):
self.actionFind_previous.setText(_translate("MainWindow", "Find previous")) self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P")) self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About")) self.action_About.setText(_translate("MainWindow", "&About"))
self.actionSave_as_template.setText( self.actionSave_as_template.setText(_translate("MainWindow", "Save as template..."))
_translate("MainWindow", "Save as template...") self.actionNew_from_template.setText(_translate("MainWindow", "New from template..."))
)
self.actionNew_from_template.setText(
_translate("MainWindow", "New from template...")
)
self.actionDebug.setText(_translate("MainWindow", "Debug")) self.actionDebug.setText(_translate("MainWindow", "Debug"))
self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1...")) self.actionAdd_cart.setText(_translate("MainWindow", "Edit cart &1..."))
self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving")) self.actionMark_for_moving.setText(_translate("MainWindow", "Mark for moving"))
@ -822,23 +672,11 @@ class Ui_MainWindow(object):
self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V")) self.actionPaste.setShortcut(_translate("MainWindow", "Ctrl+V"))
self.actionResume.setText(_translate("MainWindow", "Resume")) self.actionResume.setText(_translate("MainWindow", "Resume"))
self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R")) self.actionResume.setShortcut(_translate("MainWindow", "Ctrl+R"))
self.actionSearch_title_in_Wikipedia.setText( self.actionSearch_title_in_Wikipedia.setText(_translate("MainWindow", "Search title in Wikipedia"))
_translate("MainWindow", "Search title in Wikipedia") self.actionSearch_title_in_Wikipedia.setShortcut(_translate("MainWindow", "Ctrl+W"))
) self.actionSearch_title_in_Songfacts.setText(_translate("MainWindow", "Search title in Songfacts"))
self.actionSearch_title_in_Wikipedia.setShortcut( self.actionSearch_title_in_Songfacts.setShortcut(_translate("MainWindow", "Ctrl+S"))
_translate("MainWindow", "Ctrl+W") self.actionSelect_duplicate_rows.setText(_translate("MainWindow", "Select duplicate rows..."))
)
self.actionSearch_title_in_Songfacts.setText(
_translate("MainWindow", "Search title in Songfacts")
)
self.actionSearch_title_in_Songfacts.setShortcut(
_translate("MainWindow", "Ctrl+S")
)
self.actionSelect_duplicate_rows.setText(
_translate("MainWindow", "Select duplicate rows...")
)
self.actionReplace_files.setText(_translate("MainWindow", "Replace files...")) self.actionReplace_files.setText(_translate("MainWindow", "Replace files..."))
from infotabs import InfoTabs from infotabs import InfoTabs
from pyqtgraph import PlotWidget from pyqtgraph import PlotWidget

View File

@ -18,6 +18,7 @@ if os.path.exists(DB_FILE):
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app.models import ( # noqa: E402 from app.models import ( # noqa: E402
db, db,
Carts,
NoteColours, NoteColours,
Playdates, Playdates,
Playlists, Playlists,
@ -152,7 +153,7 @@ class TestMMModels(unittest.TestCase):
assert len(Playlists.get_open(session)) == 1 assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0 assert len(Playlists.get_closed(session)) == 0
playlist.close(session) playlist.close()
assert len(Playlists.get_open(session)) == 0 assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1 assert len(Playlists.get_closed(session)) == 1
@ -255,6 +256,12 @@ class TestMMModels(unittest.TestCase):
colour = nc3.get_colour(session, BAD_STRING) colour = nc3.get_colour(session, BAD_STRING)
assert colour is None assert colour is None
def test_create_cart(self):
with db.Session() as session:
cart = Carts(session, 1, "name")
assert cart
_ = str(cart)
def test_name_available(self): def test_name_available(self):
PLAYLIST_NAME = "a name" PLAYLIST_NAME = "a name"
RENAME = "new name" RENAME = "new name"

View File

@ -19,9 +19,14 @@ os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app import playlistmodel, utilities from app import playlistmodel, utilities
from app.models import ( # noqa: E402 from app.models import ( # noqa: E402
db, db,
Carts,
NoteColours,
Playdates,
Playlists, Playlists,
PlaylistRows,
Tracks, Tracks,
) )
from app import playlists
from app import musicmuster from app import musicmuster