Compare commits

...

18 Commits

Author SHA1 Message Date
Keith Edmunds
a46b9a3d6f Return True/False on set_next_row 2024-06-03 20:29:50 +01:00
Keith Edmunds
1ee9a1ae22 Speed up moving rows 2024-06-03 20:29:17 +01:00
Keith Edmunds
e884201df4 Don't accept unreadable track into _TrackManager 2024-06-03 19:06:00 +01:00
Keith Edmunds
2f32f2e914 Update fade graph when starting next track before current has finished 2024-06-03 19:05:19 +01:00
Keith Edmunds
1d51edc50f Most recent track first in tooltips 2024-06-02 21:05:09 +01:00
Keith Edmunds
35b5402853 Fix: end of preview caused main play end of track actions 2024-06-02 20:53:26 +01:00
Keith Edmunds
2a1d9e94bc All tests pass 2024-06-02 19:33:41 +01:00
Keith Edmunds
9f7af072dc Remove carts from tests 2024-06-02 19:28:26 +01:00
Keith Edmunds
648ef76234 Resume working 2024-06-02 19:19:35 +01:00
Keith Edmunds
909fb27bed All preview/intro management working 2024-06-02 17:58:20 +01:00
Keith Edmunds
09fdd7e4dc Display of countdown timer works 2024-06-02 16:50:49 +01:00
Keith Edmunds
983716e009 Row times updating working 2024-06-02 16:34:30 +01:00
Keith Edmunds
4ec1c0e09c Fade graph no longer lagging 2024-06-02 14:31:14 +01:00
Keith Edmunds
0361d25c7b WIP: fade graph working, slightly laggy 2024-06-02 13:33:57 +01:00
Keith Edmunds
c5ca1469dc Remove all carts code 2024-06-02 12:04:26 +01:00
Keith Edmunds
5278b124ca WIP: implemented trackmanager, tracks play, clocks work 2024-06-02 11:57:45 +01:00
Keith Edmunds
fbcedb6c3b Create trackmanager.py
music.py is fully absorbed into trackmanager.py and thus removed
Substantial parts of classes.py are absorbed into trackmanager.py
2024-06-02 10:00:31 +01:00
Keith Edmunds
8ea0a0dad5 WIP: moving player to PlaylistTrack. Player works. 2024-06-01 17:41:22 +01:00
12 changed files with 1294 additions and 1249 deletions

View File

@ -2,20 +2,13 @@
from dataclasses import dataclass, field
from enum import auto, Enum
from typing import Any, Optional
import datetime as dt
# PyQt imports
from PyQt6.QtCore import pyqtSignal, QObject, QThread
from PyQt6.QtCore import pyqtSignal, QObject
# Third party imports
import numpy as np
import pyqtgraph as pg # type: ignore
from sqlalchemy.orm import scoped_session
# App imports
from config import Config
from log import log
from models import PlaylistRows
import helpers
@ -32,62 +25,6 @@ class Col(Enum):
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
@dataclass
class MusicMusterSignals(QObject):
@ -111,117 +48,12 @@ class MusicMusterSignals(QObject):
show_warning_signal = pyqtSignal(str, str)
span_cells_signal = pyqtSignal(int, int, int, int, int)
status_message_signal = pyqtSignal(str, int)
track_ended_signal = pyqtSignal()
def __post_init__(self):
super().__init__()
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
class TrackFileData:
"""
@ -234,46 +66,3 @@ class TrackFileData:
obsolete_path: Optional[str] = None
tags: dict[str, Any] = 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,17 +9,9 @@ class Config(object):
AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300
CART_DIRECTORY = "/home/kae/radio/CartTracks"
CARTS_COUNT = 10
CARTS_HIDE = True
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
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_TAB = "#248f24"
COLOUR_ENDING_TIMER = "#dc3545"
@ -60,7 +52,6 @@ class Config(object):
HEADER_TITLE = "Title"
HIDE_AFTER_PLAYING_OFFSET = 5000
INFO_TAB_TITLE_LENGTH = 15
INTRO_END_GAP_MS = 1000
INTRO_SECONDS_FORMAT = ".1f"
INTRO_SECONDS_WARNING_MS = 3000
LAST_PLAYED_TODAY_STRING = "Today"
@ -86,6 +77,7 @@ class Config(object):
PLAY_SETTLE = 500000
PREVIEW_ADVANCE_MS = 5000
PREVIEW_BACK_MS = 5000
PREVIEW_END_BUFFER_MS = 1000
REPLACE_FILES_DEFAULT_SOURCE = "/home/kae/music/Singles/tmp"
RETURN_KEY_DEBOUNCE_MS = 500
ROOT = os.environ.get("ROOT") or "/home/kae/music"

View File

@ -23,23 +23,6 @@ from sqlalchemy.orm import (
# 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):
__tablename__ = "notecolours"

View File

@ -37,28 +37,6 @@ db = Alchemical(ALCHEMICAL_DATABASE_URI, engine_options=Config.ENGINE_OPTIONS)
# 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):
def __init__(
self,

View File

@ -1,260 +0,0 @@
# 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,23 +2,19 @@
# Standard library imports
from os.path import basename
from time import sleep
from typing import cast, List, Optional
from typing import List, Optional
import argparse
import datetime as dt
import os
import shutil
import subprocess
import sys
import threading
# PyQt imports
from PyQt6.QtCore import (
pyqtSignal,
QDate,
QEvent,
QObject,
QSize,
Qt,
QThread,
QTime,
@ -27,10 +23,7 @@ from PyQt6.QtCore import (
from PyQt6.QtGui import (
QCloseEvent,
QColor,
QFont,
QMouseEvent,
QPalette,
QResizeEvent,
)
from PyQt6.QtWidgets import (
QApplication,
@ -42,12 +35,9 @@ from PyQt6.QtWidgets import (
QListWidgetItem,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
)
# Third party imports
# from pygame import mixer
import pipeclient
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session
@ -55,93 +45,26 @@ import stackprinter # type: ignore
# App imports
from classes import (
track_sequence,
FadeCurve,
MusicMusterSignals,
PlaylistTrack,
TrackFileData,
)
from config import Config
from dialogs import TrackSelectDialog, ReplaceFilesDialog
from log import log
from models import db, Carts, Playdates, PlaylistRows, Playlists, Settings, Tracks
from models import db, Playdates, PlaylistRows, Playlists, Settings, Tracks
from playlistmodel import PlaylistModel, PlaylistProxyModel
from playlists import PlaylistTab
from trackmanager import (
MainTrackManager,
PreviewTrackManager,
track_sequence,
)
from ui import icons_rc # noqa F401
from ui.dlg_cart_ui import Ui_DialogCartEdit # type: ignore
from ui.dlg_SelectPlaylist_ui import Ui_dlgSelectPlaylist # type: ignore
from ui.downloadcsv_ui import Ui_DateSelect # type: ignore
from ui.main_window_ui import Ui_MainWindow # type: ignore
from utilities import check_db, update_bitrates
import helpers
import music
class CartButton(QPushButton):
"""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):
@ -230,11 +153,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.timer500: QTimer = QTimer()
self.timer1000: QTimer = QTimer()
self.music: music.Music = music.Music(name=Config.VLC_MAIN_PLAYER_NAME)
self.preview_player: music.Music = music.Music(
name=Config.VLC_PREVIEW_PLAYER_NAME
)
self.playing: bool = False
self.preview_track_manager: Optional[PreviewTrackManager] = None
self.set_main_window_size()
self.lblSumPlaytime = QLabel("")
@ -248,7 +167,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.widgetFadeVolume.hideAxis("left")
self.widgetFadeVolume.setDefaultPadding(0)
self.widgetFadeVolume.setBackground(Config.FADE_CURVE_BACKGROUND)
FadeCurve.GraphWidget = self.widgetFadeVolume
self.active_tab = lambda: self.tabPlaylist.currentWidget()
self.active_proxy_model = lambda: self.tabPlaylist.currentWidget().model()
@ -258,11 +176,6 @@ class Window(QMainWindow, Ui_MainWindow):
self.audacity_client: Optional[pipeclient.PipeClient] = None
self.initialise_audacity()
if Config.CARTS_HIDE:
self.cartsWidget.hide()
self.frame_6.hide()
else:
self.carts_init()
self.disable_selection_timing = False
self.clock_counter = 0
self.timer10.start(10)
@ -295,142 +208,12 @@ class Window(QMainWindow, Ui_MainWindow):
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:
"""
Clear next track
"""
track_sequence.next = PlaylistTrack()
track_sequence.next = None
self.update_headers()
def clear_selection(self) -> None:
@ -449,7 +232,7 @@ class Window(QMainWindow, Ui_MainWindow):
return
# Don't allow window to close when a track is playing
if self.playing:
if track_sequence.current and track_sequence.current.is_playing():
event.ignore()
helpers.show_warning(
self, "Track playing", "Can't close application while track is playing"
@ -521,7 +304,10 @@ class Window(QMainWindow, Ui_MainWindow):
"""
# Don't close current track playlist
current_track_playlist_id = track_sequence.now.playlist_id
if track_sequence.current is None:
return True
current_track_playlist_id = track_sequence.current.playlist_id
closing_tab_playlist_id = self.tabPlaylist.widget(tab_index).playlist_id
if current_track_playlist_id:
if closing_tab_playlist_id == current_track_playlist_id:
@ -601,6 +387,7 @@ class Window(QMainWindow, Ui_MainWindow):
self.signals.next_track_changed_signal.connect(self.update_headers)
self.signals.status_message_signal.connect(self.show_status_message)
self.signals.show_warning_signal.connect(self.show_warning)
self.signals.track_ended_signal.connect(self.end_of_track_actions)
self.timer10.timeout.connect(self.tick_10ms)
self.timer500.timeout.connect(self.tick_500ms)
@ -721,10 +508,8 @@ class Window(QMainWindow, Ui_MainWindow):
def drop3db(self) -> None:
"""Drop music level by 3db if button checked"""
if self.btnDrop3db.isChecked():
self.music.set_volume(Config.VLC_VOLUME_DROP3db, set_default=False)
else:
self.music.set_volume(Config.VLC_VOLUME_DEFAULT, set_default=False)
if track_sequence.current:
track_sequence.current.drop3db(self.btnDrop3db.isChecked())
def enable_escape(self, enabled: bool) -> None:
"""
@ -738,6 +523,38 @@ class Window(QMainWindow, Ui_MainWindow):
self.action_Clear_selection.setEnabled(enabled)
def end_of_track_actions(self) -> None:
"""
Actions required:
- Reset track_sequence objects
- Tell model track has finished
- Reset clocks
- Update headers
- Enable controls
"""
# Reset track_sequence objects
track_sequence.previous = track_sequence.current
track_sequence.current = None
# Tell model previous track has finished
self.active_proxy_model().previous_track_ended()
# Reset clocks
self.frame_fade.setStyleSheet("")
self.frame_silent.setStyleSheet("")
self.label_elapsed_timer.setText("00:00 / 00:00")
self.label_fade_timer.setText("00:00")
self.label_silent_timer.setText("00:00")
# Update headers
self.update_headers()
# Enable controls
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
def export_playlist_tab(self) -> None:
"""Export the current playlist to an m3u file"""
@ -785,7 +602,8 @@ class Window(QMainWindow, Ui_MainWindow):
def fade(self) -> None:
"""Fade currently playing track"""
self.stop_playing(fade=True)
if track_sequence.current:
track_sequence.current.fade()
def hide_played(self):
"""Toggle hide played tracks"""
@ -1145,11 +963,12 @@ class Window(QMainWindow, Ui_MainWindow):
"""
# Check for inadvertent press of 'return'
if self.catch_return_key:
if track_sequence.current and self.catch_return_key:
# Suppress inadvertent double press
if (
track_sequence.now.start_time
and track_sequence.now.start_time
track_sequence.current
and track_sequence.current.start_time
and track_sequence.current.start_time
+ dt.timedelta(milliseconds=Config.RETURN_KEY_DEBOUNCE_MS)
> dt.datetime.now()
):
@ -1157,8 +976,8 @@ class Window(QMainWindow, Ui_MainWindow):
# If return is pressed during first PLAY_NEXT_GUARD_MS then
# default to NOT playing the next track, else default to
# playing it.
default_yes: bool = track_sequence.now.start_time is not None and (
(dt.datetime.now() - track_sequence.now.start_time).total_seconds()
default_yes: bool = track_sequence.current.start_time is not None and (
(dt.datetime.now() - track_sequence.current.start_time).total_seconds()
* 1000
> Config.PLAY_NEXT_GUARD_MS
)
@ -1170,32 +989,30 @@ class Window(QMainWindow, Ui_MainWindow):
):
return
log.info(f"play_next({position=})")
log.debug(f"play_next({position=})")
# If there is no next track set, return.
if not track_sequence.next.track_id:
if track_sequence.next is None:
log.error("musicmuster.play_next(): no next track selected")
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
# when starting to play at track.
# Resolution appears to be to disable timer10 for the first ten
# seconds of playback. Re-enabled tick_1000ms
# seconds of playback. Re-enable in update_clocks.
self.timer10.stop()
self.show_status_message("10ms timer disabled", 0)
log.debug("10ms timer disabled", 0)
# If there's currently a track playing, fade it.
self.stop_playing(fade=True)
if track_sequence.current:
track_sequence.current.fade()
# Move next track to current track.
# stop_playing() above has called end_of_track_actions()
# which will have populated self.previous_track
track_sequence.now = track_sequence.next
# end_of_track_actions() will have saved current track to
# previous_track
track_sequence.current = track_sequence.next
# Clear next track
self.clear_next()
@ -1206,22 +1023,19 @@ class Window(QMainWindow, Ui_MainWindow):
self.btnDrop3db.setChecked(False)
# Play (new) current track
if not track_sequence.now.path:
log.error("No path for next track")
return
self.music.play(track_sequence.now.path, position)
track_sequence.current.play(position)
# Update clocks now, don't wait for next tick
self.update_clocks()
# Show closing volume graph
if track_sequence.now.fade_graph:
track_sequence.now.fade_graph.plot()
if track_sequence.current.fade_graph:
track_sequence.current.fade_graph.GraphWidget = self.widgetFadeVolume
track_sequence.current.fade_graph.clear()
track_sequence.current.fade_graph.plot()
else:
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
self.catch_return_key = True
self.show_status_message("Play controls: Disabled", 0)
@ -1238,7 +1052,7 @@ class Window(QMainWindow, Ui_MainWindow):
for lp in last_played:
track = session.get(Tracks, lp.track_id)
tracklist.append(f"{track.title} ({track.artist})")
tt = "<br>".join(reversed(tracklist))
tt = "<br>".join(tracklist)
self.hdrPreviousTrack.setToolTip(tt)
@ -1248,19 +1062,35 @@ class Window(QMainWindow, Ui_MainWindow):
"""
if self.btnPreview.isChecked():
# Get track path for first selected track if there is one
track_path = self.active_tab().get_selected_row_track_path()
if not track_path:
# Otherwise get path to next track to play
track_path = track_sequence.next.path
if not track_path:
# Get track_id for first selected track if there is one
row_number_and_track_id = self.active_tab().get_selected_row_and_track_id()
if row_number_and_track_id:
row_number, track_id = row_number_and_track_id
else:
# Otherwise get track_id to next track to play
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)
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:
self.preview_player.stop()
if self.preview_track_manager:
self.preview_track_manager.stop()
self.preview_track_manager = None
self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreviewMark.setEnabled(False)
self.btnPreviewArm.setChecked(False)
@ -1272,51 +1102,45 @@ class Window(QMainWindow, Ui_MainWindow):
def preview_back(self) -> None:
"""Wind back preview file"""
self.preview_player.move_back(Config.PREVIEW_BACK_MS)
if self.preview_track_manager:
self.preview_track_manager.move_back()
def preview_end(self) -> None:
"""Advance preview file to just before end of intro"""
"""Advance preview file to Config.PREVIEW_END_BUFFER_MS before end of intro"""
return
# preview_track_path = self.preview_player.path
# if not preview_track_path:
# return
# with Session() as session:
# preview_track = Tracks.get_by_path(session, preview_track_path)
# if not preview_track or not preview_track.intro:
# return
# new_position = max(0, preview_track.intro - Config.INTRO_END_GAP_MS)
# self.preview_player.set_position(new_position)
if self.preview_track_manager:
self.preview_track_manager.move_to_intro_end()
def preview_fwd(self) -> None:
"""Advance preview file"""
self.preview_player.move_forward(Config.PREVIEW_ADVANCE_MS)
if self.preview_track_manager:
self.preview_track_manager.move_forward()
def preview_mark(self) -> None:
"""Set intro time"""
track_id = self.active_tab().get_selected_row_track_id()
row_number = self.active_tab().get_selected_row()
if track_id:
if self.preview_track_manager:
track_id = self.preview_track_manager.track_id
row_number = self.preview_track_manager.row_number
with db.Session() as session:
track = session.get(Tracks, track_id)
if track:
# Save intro as millisends rounded to nearest 0.1
# second because editor spinbox only resolves to 0.1
# seconds
track.intro = round(self.preview_player.get_playtime() / 100) * 100
intro = round(self.preview_track_manager.time_playing() / 100) * 100
track.intro = intro
session.commit()
self.preview_track_manager.intro = intro
self.active_tab().source_model.refresh_row(session, row_number)
self.active_tab().source_model.invalidate_row(row_number)
def preview_start(self) -> None:
"""Advance preview file"""
"""Restart preview"""
self.preview_player.set_position(0)
if self.preview_track_manager:
self.preview_track_manager.restart()
def rename_playlist(self) -> None:
"""
@ -1414,7 +1238,8 @@ class Window(QMainWindow, Ui_MainWindow):
- If a track is playing, make that the next track
"""
log.info("resume()")
if not track_sequence.previous:
return
# Return if no saved position
if not track_sequence.previous.resume_marker:
@ -1432,12 +1257,15 @@ class Window(QMainWindow, Ui_MainWindow):
# We need to fake the start time to reflect where we resumed the
# track
if (
track_sequence.now.start_time
and track_sequence.now.duration
and track_sequence.now.resume_marker
track_sequence.current
and track_sequence.current.start_time
and track_sequence.current.duration
and track_sequence.current.resume_marker
):
elapsed_ms = track_sequence.now.duration * track_sequence.now.resume_marker
track_sequence.now.start_time -= dt.timedelta(milliseconds=elapsed_ms)
elapsed_ms = (
track_sequence.current.duration * track_sequence.current.resume_marker
)
track_sequence.current.start_time -= dt.timedelta(milliseconds=elapsed_ms)
def save_as_template(self) -> None:
"""Save current playlist as template"""
@ -1563,7 +1391,8 @@ class Window(QMainWindow, Ui_MainWindow):
def show_current(self) -> None:
"""Scroll to show current track"""
self.show_track(track_sequence.now)
if track_sequence.current:
self.show_track(track_sequence.current)
def show_warning(self, title: str, body: str) -> None:
"""
@ -1576,6 +1405,7 @@ class Window(QMainWindow, Ui_MainWindow):
def show_next(self) -> None:
"""Scroll to show next track"""
if track_sequence.next:
self.show_track(track_sequence.next)
def show_status_message(self, message: str, timing: int) -> None:
@ -1585,25 +1415,27 @@ class Window(QMainWindow, Ui_MainWindow):
self.statusbar.showMessage(message, timing)
def show_track(self, plt: PlaylistTrack) -> None:
def show_track(self, playlist_track: MainTrackManager) -> None:
"""Scroll to show track in plt"""
# Switch to the correct tab
plt_playlist_id = plt.playlist_id
if not plt_playlist_id:
playlist_id = playlist_track.playlist_id
if not playlist_id:
# No playlist
return
if plt_playlist_id != self.active_tab().playlist_id:
if playlist_id != self.active_tab().playlist_id:
for idx in range(self.tabPlaylist.count()):
if self.tabPlaylist.widget(idx).playlist_id == plt_playlist_id:
if self.tabPlaylist.widget(idx).playlist_id == playlist_id:
self.tabPlaylist.setCurrentIndex(idx)
break
display_row = (
self.active_proxy_model()
.mapFromSource(
self.active_proxy_model().source_model.index(plt.plr_rownum, 0)
self.active_proxy_model().source_model.index(
playlist_track.row_number, 0
)
)
.row()
)
@ -1639,66 +1471,8 @@ class Window(QMainWindow, Ui_MainWindow):
def stop(self) -> None:
"""Stop playing immediately"""
self.stop_playing(fade=False)
def stop_playing(self, fade: bool = True) -> None:
"""
Stop playing current track
Actions required:
- Set flag to say we're not playing a track
- Return if not playing
- Stop/fade track
- Reset playlist_tab colour
- Tell playlist_tab track has finished
- Reset PlaylistTrack objects
- Reset clocks
- Reset fade graph
- Update headers
- Enable controls
"""
# Set flag to say we're not playing a track so that timer ticks
# don't see player=None and kick off end-of-track actions
if self.playing:
self.playing = False
else:
# Return if not playing
log.info("stop_playing() called but not playing")
return
# Stop/fade track
track_sequence.now.resume_marker = self.music.get_position()
if fade:
self.music.fade()
else:
self.music.stop()
# Reset fade graph
if track_sequence.now.fade_graph:
track_sequence.now.fade_graph.clear()
# Reset track_sequence objects
if track_sequence.now.track_id:
track_sequence.previous = track_sequence.now
track_sequence.now = PlaylistTrack()
# Tell model previous track has finished
self.active_proxy_model().previous_track_ended()
# Reset clocks
self.frame_fade.setStyleSheet("")
self.frame_silent.setStyleSheet("")
self.label_elapsed_timer.setText("00:00 / 00:00")
self.label_fade_timer.setText("00:00")
self.label_silent_timer.setText("00:00")
# Update headers
self.update_headers()
# Enable controls
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
if track_sequence.current:
track_sequence.current.stop()
def tab_change(self):
"""Called when active tab changed"""
@ -1710,42 +1484,22 @@ class Window(QMainWindow, Ui_MainWindow):
Called every 10ms
"""
# Update volume fade curve
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()
if track_sequence.current:
track_sequence.current.update_fade_graph()
def tick_100ms(self) -> None:
"""
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
# because playing an intro takes precedence over timing a
# preview.
if self.music.is_playing() and track_sequence.now.intro:
remaining_ms = track_sequence.now.intro - self.music.get_playtime()
remaining_ms = track_sequence.current.time_remaining_intro()
if remaining_ms > 0:
self.label_intro_timer.setText(f"{remaining_ms / 1000:.1f}")
if remaining_ms <= Config.INTRO_SECONDS_WARNING_MS:
@ -1755,24 +1509,28 @@ class Window(QMainWindow, Ui_MainWindow):
return
else:
self.label_intro_timer.setStyleSheet("")
except AttributeError:
# currnent track ended during servicing tick
pass
# Ensure preview button is reset if preview finishes playing
self.btnPreview.setChecked(self.preview_player.is_playing())
if self.preview_track_manager:
self.btnPreview.setChecked(self.preview_track_manager.is_playing())
# Update preview timer
if self.preview_player.is_playing():
playtime = self.preview_player.get_playtime()
if self.preview_track_manager.is_playing():
playtime = self.preview_track_manager.time_playing()
self.label_intro_timer.setText(f"{playtime / 1000:.1f}")
if playtime <= 0:
self.label_intro_timer.setStyleSheet(
f"background: {Config.COLOUR_ENDING_TIMER}"
)
elif playtime <= Config.INTRO_SECONDS_WARNING_MS:
if self.preview_track_manager.time_remaining_intro() <= 50:
self.label_intro_timer.setStyleSheet(
f"background: {Config.COLOUR_WARNING_TIMER}"
)
else:
self.label_intro_timer.setStyleSheet("")
else:
self.label_intro_timer.setText("0.0")
self.label_intro_timer.setStyleSheet("")
self.btnPreview.setChecked(False)
def tick_1000ms(self) -> None:
"""
@ -1782,28 +1540,41 @@ class Window(QMainWindow, Ui_MainWindow):
# Only update play clocks once a second so that their updates
# are synchronised (otherwise it looks odd)
if not self.playing:
return
self.update_clocks()
def 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 self.music.player and self.music.player.is_playing():
playtime = self.music.get_playtime()
time_to_fade = track_sequence.now.fade_at - playtime
time_to_silence = track_sequence.now.silence_at - playtime
# see play_next() and issue #223
if playtime > 10000 and not self.timer10.isActive():
if track_sequence.current and track_sequence.current.is_playing():
# see play_next() and issue #223.
# TODO: find a better way of handling this
if (
track_sequence.current.time_playing() > 10000
and not self.timer10.isActive()
):
self.timer10.start(10)
self.show_status_message("10ms timer enabled", 0)
log.debug("10ms timer enabled")
# Elapsed time
self.label_elapsed_timer.setText(
helpers.ms_to_mmss(playtime)
helpers.ms_to_mmss(track_sequence.current.time_playing())
+ " / "
+ helpers.ms_to_mmss(track_sequence.now.duration)
+ helpers.ms_to_mmss(track_sequence.current.duration)
)
# Time to fade
time_to_fade = track_sequence.current.time_to_fade()
time_to_silence = track_sequence.current.time_to_silence()
self.label_fade_timer.setText(helpers.ms_to_mmss(time_to_fade))
# If silent in the next 5 seconds, put warning colour on
@ -1814,11 +1585,13 @@ class Window(QMainWindow, Ui_MainWindow):
self.frame_silent.setStyleSheet(css_silence)
self.catch_return_key = False
self.show_status_message("Play controls: Enabled", 0)
# Set warning colour on time to silence box when fade starts
elif time_to_fade <= 500:
css_fade = f"background: {Config.COLOUR_WARNING_TIMER}"
if self.frame_silent.styleSheet() != css_fade:
self.frame_silent.setStyleSheet(css_fade)
# Five seconds before fade starts, set warning colour on
# time to silence box and enable play controls
elif time_to_fade <= Config.WARNING_MS_BEFORE_FADE:
@ -1833,34 +1606,27 @@ class Window(QMainWindow, Ui_MainWindow):
self.label_silent_timer.setText(helpers.ms_to_mmss(time_to_silence))
# Autoplay next track
# if time_to_silence <= 1500:
# self.play_next()
else:
if self.playing:
self.stop_playing()
def update_headers(self) -> None:
"""
Update last / current / next track headers
"""
if track_sequence.previous.title and track_sequence.previous.artist:
if track_sequence.previous:
self.hdrPreviousTrack.setText(
f"{track_sequence.previous.title} - {track_sequence.previous.artist}"
)
else:
self.hdrPreviousTrack.setText("")
if track_sequence.now.title and track_sequence.now.artist:
if track_sequence.current:
self.hdrCurrentTrack.setText(
f"{track_sequence.now.title.replace('&', '&&')} - "
f"{track_sequence.now.artist.replace('&', '&&')}"
f"{track_sequence.current.title.replace('&', '&&')} - "
f"{track_sequence.current.artist.replace('&', '&&')}"
)
else:
self.hdrCurrentTrack.setText("")
if track_sequence.next.title and track_sequence.next.artist:
if track_sequence.next:
self.hdrNextTrack.setText(
f"{track_sequence.next.title.replace('&', '&&')} - "
f"{track_sequence.next.artist.replace('&', '&&')}"
@ -1869,44 +1635,6 @@ class Window(QMainWindow, Ui_MainWindow):
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):
def __init__(self, parent=None):
super().__init__()

View File

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

View File

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

668
app/trackmanager.py Normal file
View File

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

View File

@ -18,7 +18,6 @@ if os.path.exists(DB_FILE):
os.environ["ALCHEMICAL_DATABASE_URI"] = "sqlite:///" + DB_FILE
from app.models import ( # noqa: E402
db,
Carts,
NoteColours,
Playdates,
Playlists,
@ -153,7 +152,7 @@ class TestMMModels(unittest.TestCase):
assert len(Playlists.get_open(session)) == 1
assert len(Playlists.get_closed(session)) == 0
playlist.close()
playlist.close(session)
assert len(Playlists.get_open(session)) == 0
assert len(Playlists.get_closed(session)) == 1
@ -256,12 +255,6 @@ class TestMMModels(unittest.TestCase):
colour = nc3.get_colour(session, BAD_STRING)
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):
PLAYLIST_NAME = "a name"
RENAME = "new name"

View File

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