Import existing LinTunes project
Snapshot of the existing codebase before working through the TASKS.md backlog. Real library data (data/) and the iTunes import fixture (itunes-test-library/) are gitignored.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPainter, QPixmap
|
||||
|
||||
|
||||
class ArtWindow(QWidget):
|
||||
"""Non-modal window showing the current track's album art as large as
|
||||
possible. Just an album art window — closing it has no side effects."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self._pixmap: QPixmap | None = None
|
||||
self.setMinimumSize(120, 120)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
|
||||
|
||||
def set_artwork(self, pixmap: QPixmap | None, title: str):
|
||||
self.setWindowTitle(title or "Album Art")
|
||||
self._pixmap = pixmap
|
||||
if pixmap is not None and not self.isVisible():
|
||||
screen = QApplication.primaryScreen().availableGeometry()
|
||||
width = min(pixmap.width(), int(screen.width() * 0.8))
|
||||
height = min(pixmap.height(), int(screen.height() * 0.8))
|
||||
self.resize(max(width, 240), max(height, 240))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), Qt.GlobalColor.black)
|
||||
if self._pixmap is None or self._pixmap.isNull():
|
||||
return
|
||||
scaled = self._pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
x = (self.width() - scaled.width()) // 2
|
||||
y = (self.height() - scaled.height()) // 2
|
||||
painter.drawPixmap(x, y, scaled)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""A cursor-following drag indicator that works on Wayland.
|
||||
|
||||
GNOME/Mutter does not render the translucent drag-icon surface produced by
|
||||
``QDrag.setPixmap()``, and the other native hooks (``setDragCursor``,
|
||||
``setOverrideCursor``) are unreliable during a drag too. So instead of asking
|
||||
the compositor to draw the drag image, we paint our own frameless overlay as a
|
||||
child of the top-level window and move it ourselves from the drag-move events
|
||||
Qt delivers to our drop targets (``QCursor.pos()`` is also unreliable on
|
||||
Wayland, so we never poll it).
|
||||
|
||||
The little song icon shown under the cursor is loaded from
|
||||
``packaging/drag-icon.png`` — the same folder as the application icon — so it
|
||||
can be swapped out and customized. If that file is missing we fall back to a
|
||||
hand-drawn page-and-note glyph so the feedback never silently disappears.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import Qt, QPoint, QPointF, QRectF
|
||||
from PyQt6.QtGui import QPixmap, QPainter, QColor, QPen, QPolygonF
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
# Sits next to packaging/lintunes.png (the app-icon source). parents[2] is the
|
||||
# repo root: lintunes/gui/drag_ghost.py -> gui -> lintunes -> <root>.
|
||||
ICON_PATH = Path(__file__).resolve().parents[2] / "packaging" / "drag-icon.png"
|
||||
|
||||
_base_cache: QPixmap | None = None
|
||||
|
||||
|
||||
def _base_pixmap() -> QPixmap:
|
||||
"""The full-resolution source icon (customizable PNG or drawn fallback)."""
|
||||
global _base_cache
|
||||
if _base_cache is None:
|
||||
pixmap = QPixmap(str(ICON_PATH))
|
||||
if pixmap.isNull():
|
||||
pixmap = _draw_fallback(256)
|
||||
_base_cache = pixmap
|
||||
return _base_cache
|
||||
|
||||
|
||||
def song_icon(size: int, ratio: float = 1.0) -> QPixmap:
|
||||
"""The drag icon scaled to ``size`` logical points for the given DPR."""
|
||||
px = max(1, round(size * ratio))
|
||||
scaled = _base_pixmap().scaled(
|
||||
px, px, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
scaled.setDevicePixelRatio(ratio)
|
||||
return scaled
|
||||
|
||||
|
||||
def _draw_fallback(size: int) -> QPixmap:
|
||||
"""A simple page-with-folded-corner and a music note, app-icon style."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
p = QPainter(pixmap)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
s = size
|
||||
# Page body with a folded top-right corner.
|
||||
fold = s * 0.26
|
||||
body = QPolygonF([
|
||||
QPointF(s * 0.18, s * 0.10), QPointF(s * 0.66, s * 0.10),
|
||||
QPointF(s * 0.82, s * 0.10 + fold), QPointF(s * 0.82, s * 0.90),
|
||||
QPointF(s * 0.18, s * 0.90),
|
||||
])
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(QColor("#f4f4f5"))
|
||||
p.drawPolygon(body)
|
||||
p.setBrush(QColor("#c8c8cc"))
|
||||
p.drawPolygon(QPolygonF([
|
||||
QPointF(s * 0.66, s * 0.10), QPointF(s * 0.66, s * 0.10 + fold),
|
||||
QPointF(s * 0.82, s * 0.10 + fold),
|
||||
]))
|
||||
# Music note: a filled head plus a stem.
|
||||
note = QColor("#2b6cb0")
|
||||
p.setBrush(note)
|
||||
p.drawEllipse(QPointF(s * 0.40, s * 0.70), s * 0.085, s * 0.065)
|
||||
pen = QPen(note, max(1.0, s * 0.045))
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.drawLine(QPointF(s * 0.485, s * 0.685), QPointF(s * 0.485, s * 0.40))
|
||||
p.drawLine(QPointF(s * 0.485, s * 0.40), QPointF(s * 0.62, s * 0.46))
|
||||
p.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
class _Ghost(QWidget):
|
||||
"""Frameless, click-through overlay that just paints a pixmap."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
self._pixmap = QPixmap()
|
||||
self._hotspot = QPoint(0, 0)
|
||||
|
||||
def set_content(self, pixmap: QPixmap, hotspot: QPoint):
|
||||
self._pixmap = pixmap
|
||||
self._hotspot = hotspot
|
||||
self.setFixedSize(pixmap.deviceIndependentSize().toSize())
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event):
|
||||
painter = QPainter(self)
|
||||
painter.drawPixmap(0, 0, self._pixmap)
|
||||
|
||||
|
||||
_ghost: _Ghost | None = None
|
||||
|
||||
|
||||
def begin(window, pixmap: QPixmap, hotspot: QPoint, global_pos: QPoint):
|
||||
"""Show the drag overlay on ``window`` (a top-level widget)."""
|
||||
global _ghost
|
||||
end()
|
||||
_ghost = _Ghost(window)
|
||||
_ghost.set_content(pixmap, hotspot)
|
||||
move(global_pos)
|
||||
_ghost.show()
|
||||
_ghost.raise_()
|
||||
|
||||
|
||||
def move(global_pos: QPoint):
|
||||
"""Reposition so the hotspot sits under the cursor. No-op when inactive."""
|
||||
if _ghost is None:
|
||||
return
|
||||
local = _ghost.parentWidget().mapFromGlobal(global_pos)
|
||||
_ghost.move(local - _ghost._hotspot)
|
||||
_ghost.raise_()
|
||||
|
||||
|
||||
def end():
|
||||
"""Tear down the overlay when the drag finishes."""
|
||||
global _ghost
|
||||
if _ghost is not None:
|
||||
_ghost.hide()
|
||||
_ghost.deleteLater()
|
||||
_ghost = None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Hand-painted transport glyphs (prev/play/pause/next/shuffle) in any color,
|
||||
so buttons can flash the theme highlight while pressed."""
|
||||
|
||||
from PyQt6.QtCore import Qt, QPointF, QRectF
|
||||
from PyQt6.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QPolygonF
|
||||
|
||||
|
||||
_cache: dict[tuple, QIcon] = {}
|
||||
|
||||
SIZE = 20
|
||||
|
||||
|
||||
def transport_icon(kind: str, color: QColor) -> QIcon:
|
||||
key = (kind, color.name())
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
pixmap = QPixmap(SIZE, SIZE)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
_DRAWERS[kind](painter, color)
|
||||
painter.end()
|
||||
icon = QIcon(pixmap)
|
||||
_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def _play(p, _color):
|
||||
p.drawPolygon(QPolygonF([QPointF(5, 3), QPointF(17, 10), QPointF(5, 17)]))
|
||||
|
||||
|
||||
def _pause(p, _color):
|
||||
p.drawRect(QRectF(5, 3.5, 3.6, 13))
|
||||
p.drawRect(QRectF(11.4, 3.5, 3.6, 13))
|
||||
|
||||
|
||||
def _next(p, _color):
|
||||
# Skip-to-next: two triangles plus a trailing bar (bare triangles = ffwd).
|
||||
p.drawPolygon(QPolygonF([QPointF(2, 4), QPointF(9, 10), QPointF(2, 16)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(9, 4), QPointF(16, 10), QPointF(9, 16)]))
|
||||
p.drawRect(QRectF(16.5, 4, 2, 12))
|
||||
|
||||
|
||||
def _previous(p, _color):
|
||||
# Skip-to-previous: a leading bar plus two triangles (mirror of _next).
|
||||
p.drawRect(QRectF(1.5, 4, 2, 12))
|
||||
p.drawPolygon(QPolygonF([QPointF(11, 4), QPointF(4, 10), QPointF(11, 16)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(18, 4), QPointF(11, 10), QPointF(18, 16)]))
|
||||
|
||||
|
||||
def _shuffle(p, color):
|
||||
pen = QPen(color, 1.8)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
# Two crossing strands
|
||||
p.drawLine(QPointF(2, 6), QPointF(6, 6))
|
||||
p.drawLine(QPointF(6, 6), QPointF(13, 14))
|
||||
p.drawLine(QPointF(13, 14), QPointF(16, 14))
|
||||
p.drawLine(QPointF(2, 14), QPointF(6, 14))
|
||||
p.drawLine(QPointF(6, 14), QPointF(13, 6))
|
||||
p.drawLine(QPointF(13, 6), QPointF(16, 6))
|
||||
# Arrowheads
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawPolygon(QPolygonF([QPointF(15, 3), QPointF(19, 6), QPointF(15, 9)]))
|
||||
p.drawPolygon(QPolygonF([QPointF(15, 11), QPointF(19, 14), QPointF(15, 17)]))
|
||||
|
||||
|
||||
_DRAWERS = {
|
||||
"play": _play,
|
||||
"pause": _pause,
|
||||
"next": _next,
|
||||
"previous": _previous,
|
||||
"shuffle": _shuffle,
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QFormLayout, QLineEdit, QSpinBox, QCheckBox,
|
||||
QPlainTextEdit, QHBoxLayout, QLabel, QVBoxLayout, QPushButton, QMessageBox,
|
||||
QApplication, QMenu, QFileDialog, QWidget,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QBuffer, QIODevice, pyqtSignal
|
||||
from PyQt6.QtGui import QPixmap, QKeySequence
|
||||
|
||||
from lintunes import tagging
|
||||
|
||||
|
||||
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
|
||||
|
||||
|
||||
class ArtSquare(QLabel):
|
||||
"""Clickable cover-art square: click to focus, then paste (Ctrl+V) or
|
||||
drop an image file to stage new artwork."""
|
||||
|
||||
art_staged = pyqtSignal(bytes, str) # image bytes, mime
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(120, 120)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
|
||||
self.setAcceptDrops(True)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_menu)
|
||||
self.set_artwork(None)
|
||||
|
||||
def set_artwork(self, image_bytes: bytes | None):
|
||||
if image_bytes:
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(image_bytes):
|
||||
self.setPixmap(pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation))
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 1px solid palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px solid palette(highlight); }")
|
||||
return
|
||||
self.setPixmap(QPixmap())
|
||||
self.setText("no artwork\n\nclick, then paste\nan image")
|
||||
self.setStyleSheet(
|
||||
"ArtSquare { border: 2px dashed palette(mid); color: palette(mid); }"
|
||||
"ArtSquare:focus { border: 2px dashed palette(highlight); }")
|
||||
|
||||
# ---- staging from clipboard / files ----
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.matches(QKeySequence.StandardKey.Paste):
|
||||
self.paste_from_clipboard()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def paste_from_clipboard(self):
|
||||
mime = QApplication.clipboard().mimeData()
|
||||
if mime is None:
|
||||
return
|
||||
# Prefer raw image file bytes (keeps original compression)
|
||||
for fmt in ("image/jpeg", "image/png"):
|
||||
if mime.hasFormat(fmt):
|
||||
self._stage(bytes(mime.data(fmt)), fmt)
|
||||
return
|
||||
if mime.hasImage():
|
||||
image = QApplication.clipboard().image()
|
||||
if not image.isNull():
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
image.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return
|
||||
if mime.hasUrls():
|
||||
for url in mime.urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
return
|
||||
|
||||
def _stage_file(self, path: Path) -> bool:
|
||||
if path.suffix.lower() not in _IMAGE_EXTENSIONS or not path.is_file():
|
||||
return False
|
||||
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
if path.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||
# Recode exotic formats to JPEG so all tag formats accept them
|
||||
pixmap = QPixmap(str(path))
|
||||
if pixmap.isNull():
|
||||
return False
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "JPEG", quality=92)
|
||||
self._stage(bytes(buffer.data()), "image/jpeg")
|
||||
return True
|
||||
self._stage(path.read_bytes(), mime)
|
||||
return True
|
||||
|
||||
def _stage(self, image_bytes: bytes, mime: str):
|
||||
self.set_artwork(image_bytes)
|
||||
self.art_staged.emit(image_bytes, mime)
|
||||
|
||||
def _show_menu(self, pos):
|
||||
menu = QMenu(self)
|
||||
paste = menu.addAction("Paste Image\tCtrl+V")
|
||||
choose = menu.addAction("Choose File…")
|
||||
chosen = menu.exec(self.mapToGlobal(pos))
|
||||
if chosen is paste:
|
||||
self.paste_from_clipboard()
|
||||
elif chosen is choose:
|
||||
file_name, _filter = QFileDialog.getOpenFileName(
|
||||
self, "Choose Artwork", "",
|
||||
"Images (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
|
||||
if file_name:
|
||||
self._stage_file(Path(file_name))
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls() or event.mimeData().hasImage():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and self._stage_file(Path(url.toLocalFile())):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
|
||||
|
||||
_TEXT_FIELDS = [
|
||||
("name", "Name"),
|
||||
("artist", "Artist"),
|
||||
("album_artist", "Album Artist"),
|
||||
("album", "Album"),
|
||||
("genre", "Genre"),
|
||||
("composer", "Composer"),
|
||||
("grouping", "Grouping"),
|
||||
]
|
||||
|
||||
_NUMBER_FIELDS = [
|
||||
("year", "Year", 9999),
|
||||
("track_number", "Track Number", 999),
|
||||
("track_count", "of (tracks)", 999),
|
||||
("disc_number", "Disc Number", 99),
|
||||
("disc_count", "of (discs)", 99),
|
||||
("bpm", "BPM", 999),
|
||||
]
|
||||
|
||||
# Distinguishes "every selected track shares this value" from "the tracks
|
||||
# disagree" when editing several at once.
|
||||
_MIXED = object()
|
||||
|
||||
|
||||
def _format_ms(ms: int) -> str:
|
||||
"""Render milliseconds as iTunes-style m:ss(.mmm) (millis only when set)."""
|
||||
if ms <= 0:
|
||||
return ""
|
||||
mins, rem = divmod(ms, 60_000)
|
||||
secs = rem / 1000
|
||||
if rem % 1000 == 0:
|
||||
return f"{mins}:{int(secs):02d}"
|
||||
return f"{mins}:{secs:06.3f}"
|
||||
|
||||
|
||||
def _parse_time_to_ms(text: str):
|
||||
"""Parse m:ss(.mmm) / h:mm:ss / plain seconds to ms; None if unparseable."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
parts = [float(p) for p in text.split(":")]
|
||||
except ValueError:
|
||||
return None
|
||||
seconds = 0.0
|
||||
for part in parts:
|
||||
seconds = seconds * 60 + part
|
||||
return int(round(seconds * 1000))
|
||||
|
||||
|
||||
class _TimeField(QLineEdit):
|
||||
"""Small text field that edits a duration in milliseconds as m:ss.mmm."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedWidth(90)
|
||||
self.setPlaceholderText("0:00")
|
||||
|
||||
def set_ms(self, ms: int):
|
||||
self.setText(_format_ms(ms))
|
||||
|
||||
def ms(self) -> int:
|
||||
value = _parse_time_to_ms(self.text())
|
||||
return value if value is not None else 0
|
||||
|
||||
|
||||
class InfoDialog(QDialog):
|
||||
"""iTunes-style Get Info: edits library fields and the file's real tags.
|
||||
|
||||
Single track: Previous/Next walk a multi-selection one at a time, and the
|
||||
Options block exposes custom start/stop times. Multiple tracks: a combined
|
||||
editor where fields shared by every selection are editable and applied to
|
||||
all, while fields that differ show "Mixed" and are only written if touched.
|
||||
"""
|
||||
|
||||
def __init__(self, manager, track_ids: list[int], parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._track_ids = [tid for tid in track_ids
|
||||
if tid in manager.library.tracks]
|
||||
self._index = 0
|
||||
self._multi = len(self._track_ids) > 1
|
||||
# In multi mode, only fields the user actually edits get written to
|
||||
# every track (so an untouched "Mixed" field never overwrites anything).
|
||||
self._touched: set[str] = set()
|
||||
self._loading = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
top = QHBoxLayout()
|
||||
layout.addLayout(top)
|
||||
form = QFormLayout()
|
||||
top.addLayout(form, stretch=1)
|
||||
|
||||
art_col = QVBoxLayout()
|
||||
self._art_square = ArtSquare()
|
||||
self._art_square.art_staged.connect(self._on_art_staged)
|
||||
art_col.addWidget(self._art_square)
|
||||
art_col.addStretch()
|
||||
top.addLayout(art_col)
|
||||
self._staged_art: tuple[bytes, str] | None = None
|
||||
|
||||
self._editors = {}
|
||||
for field, label in _TEXT_FIELDS:
|
||||
editor = QLineEdit()
|
||||
# textEdited fires only on user input, so loading values never
|
||||
# marks a field touched.
|
||||
editor.textEdited.connect(lambda _t, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
for field, label, maximum in _NUMBER_FIELDS:
|
||||
editor = QSpinBox()
|
||||
editor.setRange(0, maximum)
|
||||
editor.setSpecialValueText(" ")
|
||||
editor.valueChanged.connect(lambda _v, f=field: self._mark_touched(f))
|
||||
self._editors[field] = editor
|
||||
form.addRow(label + ":", editor)
|
||||
|
||||
self._compilation = QCheckBox("Album is a compilation of songs by various artists")
|
||||
self._compilation.clicked.connect(lambda: self._mark_touched("compilation"))
|
||||
form.addRow("", self._compilation)
|
||||
|
||||
self._comments = QPlainTextEdit()
|
||||
self._comments.setFixedHeight(60)
|
||||
self._comments.textChanged.connect(lambda: self._mark_touched("comments"))
|
||||
form.addRow("Comments:", self._comments)
|
||||
|
||||
# Custom start/stop times (single-track only; one absolute ms value
|
||||
# across different-length tracks isn't meaningful).
|
||||
self._start_row, self._start_check, self._start_time = \
|
||||
self._make_time_row("Start time")
|
||||
self._stop_row, self._stop_check, self._stop_time = \
|
||||
self._make_time_row("Stop time")
|
||||
form.addRow("", self._start_row)
|
||||
form.addRow("", self._stop_row)
|
||||
|
||||
self._file_label = QLabel()
|
||||
self._file_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
self._file_label.setWordWrap(True)
|
||||
layout.addWidget(self._file_label)
|
||||
|
||||
nav_row = QHBoxLayout()
|
||||
self._prev_btn = QPushButton("Previous")
|
||||
self._next_btn = QPushButton("Next")
|
||||
nav_row.addWidget(self._prev_btn)
|
||||
nav_row.addWidget(self._next_btn)
|
||||
nav_row.addStretch()
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
nav_row.addWidget(buttons)
|
||||
layout.addLayout(nav_row)
|
||||
|
||||
self._prev_btn.clicked.connect(lambda: self._navigate(-1))
|
||||
self._next_btn.clicked.connect(lambda: self._navigate(1))
|
||||
buttons.accepted.connect(self._on_ok)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
if self._multi:
|
||||
# Batch editing: no single file, no per-track art/times navigation.
|
||||
self._art_square.setVisible(False)
|
||||
self._start_row.setVisible(False)
|
||||
self._stop_row.setVisible(False)
|
||||
self._file_label.setVisible(False)
|
||||
self._prev_btn.setVisible(False)
|
||||
self._next_btn.setVisible(False)
|
||||
self._load_multi()
|
||||
else:
|
||||
self._load_current()
|
||||
|
||||
def _make_time_row(self, title: str):
|
||||
row = QWidget()
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(0, 0, 0, 0)
|
||||
check = QCheckBox(title)
|
||||
field = _TimeField()
|
||||
field.setEnabled(False)
|
||||
check.toggled.connect(field.setEnabled)
|
||||
h.addWidget(check)
|
||||
h.addWidget(field)
|
||||
h.addStretch()
|
||||
return row, check, field
|
||||
|
||||
def _mark_touched(self, field: str):
|
||||
if not self._loading:
|
||||
self._touched.add(field)
|
||||
|
||||
def _current_track(self):
|
||||
return self._manager.library.tracks[self._track_ids[self._index]]
|
||||
|
||||
def _common_value(self, field: str):
|
||||
"""The value shared by every selected track, else _MIXED."""
|
||||
tracks = self._manager.library.tracks
|
||||
values = [getattr(tracks[tid], field) for tid in self._track_ids]
|
||||
first = values[0]
|
||||
return first if all(v == first for v in values) else _MIXED
|
||||
|
||||
def _on_art_staged(self, image_bytes: bytes, mime: str):
|
||||
self._staged_art = (bytes(image_bytes), mime)
|
||||
|
||||
def _load_current(self):
|
||||
track = self._current_track()
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{track.name} — Info")
|
||||
self._staged_art = None
|
||||
existing_art = (tagging.read_embedded_artwork(track.location)
|
||||
if track.location else None)
|
||||
self._art_square.set_artwork(existing_art)
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
self._editors[field].setText(getattr(track, field) or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
self._editors[field].setValue(getattr(track, field) or 0)
|
||||
self._compilation.setChecked(track.compilation)
|
||||
self._comments.setPlainText(track.comments or "")
|
||||
self._start_check.setChecked(track.start_time > 0)
|
||||
self._start_time.setEnabled(track.start_time > 0)
|
||||
self._start_time.set_ms(track.start_time)
|
||||
self._stop_check.setChecked(track.stop_time > 0)
|
||||
self._stop_time.setEnabled(track.stop_time > 0)
|
||||
self._stop_time.set_ms(track.stop_time)
|
||||
self._file_label.setText(track.location or "")
|
||||
self._prev_btn.setEnabled(self._index > 0)
|
||||
self._next_btn.setEnabled(self._index < len(self._track_ids) - 1)
|
||||
self._loading = False
|
||||
|
||||
def _load_multi(self):
|
||||
self._loading = True
|
||||
self.setWindowTitle(f"{len(self._track_ids)} Items — Info")
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.clear()
|
||||
editor.setPlaceholderText("Mixed")
|
||||
else:
|
||||
editor.setText(common or "")
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
common = self._common_value(field)
|
||||
editor = self._editors[field]
|
||||
if common is _MIXED:
|
||||
editor.setSpecialValueText("Mixed")
|
||||
editor.setValue(0)
|
||||
else:
|
||||
editor.setValue(common or 0)
|
||||
comp = self._common_value("compilation")
|
||||
self._compilation.setChecked(False if comp is _MIXED else bool(comp))
|
||||
comments = self._common_value("comments")
|
||||
if comments is _MIXED:
|
||||
self._comments.setPlaceholderText("Mixed")
|
||||
self._comments.setPlainText("")
|
||||
else:
|
||||
self._comments.setPlainText(comments or "")
|
||||
self._loading = False
|
||||
|
||||
def _collect_fields(self) -> dict:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
fields[field] = self._editors[field].value()
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
fields["start_time"] = (self._start_time.ms()
|
||||
if self._start_check.isChecked() else 0)
|
||||
fields["stop_time"] = (self._stop_time.ms()
|
||||
if self._stop_check.isChecked() else 0)
|
||||
return fields
|
||||
|
||||
def _validate_times(self, track) -> str | None:
|
||||
start = self._start_time.ms() if self._start_check.isChecked() else 0
|
||||
stop = self._stop_time.ms() if self._stop_check.isChecked() else 0
|
||||
if start and track.total_time and start >= track.total_time:
|
||||
return "The start time is past the end of the track."
|
||||
if start and stop and stop <= start:
|
||||
return "The stop time must be after the start time."
|
||||
return None
|
||||
|
||||
def _save_current(self) -> bool:
|
||||
track = self._current_track()
|
||||
error = self._validate_times(track)
|
||||
if error:
|
||||
QMessageBox.warning(self, "Invalid times", error)
|
||||
return False
|
||||
fields = self._collect_fields()
|
||||
fields_changed = any(getattr(track, k) != v for k, v in fields.items())
|
||||
if not fields_changed and self._staged_art is None:
|
||||
return True
|
||||
if track.location:
|
||||
try:
|
||||
if self._staged_art is not None:
|
||||
tagging.write_artwork(track.location, *self._staged_art)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Tag write failed",
|
||||
f"Could not write tags to file:\n{e}")
|
||||
return False
|
||||
if self._staged_art is not None:
|
||||
# Embedding art changed the file; keep the library's size honest
|
||||
# (and trigger track_updated so the transport art refreshes)
|
||||
fields["size"] = Path(track.location).stat().st_size
|
||||
# Field edits (incl. the post-art size bump) go through the manager,
|
||||
# which writes the file tags and records the change for undo. Artwork
|
||||
# itself is written above and is intentionally not undoable.
|
||||
self._manager.edit_track_fields(track.track_id, fields)
|
||||
self._staged_art = None
|
||||
return True
|
||||
|
||||
def _save_multi(self) -> bool:
|
||||
fields = {}
|
||||
for field, _label in _TEXT_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].text().strip()
|
||||
for field, _label, _max in _NUMBER_FIELDS:
|
||||
if field in self._touched:
|
||||
fields[field] = self._editors[field].value()
|
||||
if "compilation" in self._touched:
|
||||
fields["compilation"] = self._compilation.isChecked()
|
||||
if "comments" in self._touched:
|
||||
fields["comments"] = self._comments.toPlainText().strip()
|
||||
if fields:
|
||||
self._manager.edit_tracks_fields(self._track_ids, fields)
|
||||
return True
|
||||
|
||||
def _navigate(self, delta: int):
|
||||
if not self._save_current():
|
||||
return
|
||||
self._index = max(0, min(self._index + delta, len(self._track_ids) - 1))
|
||||
self._load_current()
|
||||
|
||||
def _on_ok(self):
|
||||
saved = self._save_multi() if self._multi else self._save_current()
|
||||
if saved:
|
||||
self.accept()
|
||||
@@ -0,0 +1,305 @@
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QSplitter, QLineEdit,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
|
||||
from lintunes.gui.track_table import TrackTableView
|
||||
|
||||
|
||||
_ARTICLE_RE = re.compile(r"^the\s+", re.IGNORECASE)
|
||||
|
||||
# Pause this long after the last keystroke before re-filtering, so typing a
|
||||
# query doesn't scan the whole library on every keypress.
|
||||
_SEARCH_DEBOUNCE_MS = 250
|
||||
|
||||
# Every track field the search box matches against (substring, case-folded).
|
||||
_SEARCH_FIELDS = ("name", "artist", "album_artist", "album", "genre",
|
||||
"composer", "grouping", "comments", "year", "kind")
|
||||
|
||||
|
||||
def _matches_search(track, tokens) -> bool:
|
||||
"""True when every token appears (case-folded substring) in some searched
|
||||
field of the track. An empty token list matches everything."""
|
||||
haystack = " ".join(str(getattr(track, f) or "")
|
||||
for f in _SEARCH_FIELDS).casefold()
|
||||
return all(token.casefold() in haystack for token in tokens)
|
||||
|
||||
# Editing one of these reorganizes the genre/artist/album browser, so the
|
||||
# lists must be repopulated and the table re-filtered (a plain row repaint
|
||||
# would leave the renamed value at its old spot and filtering by the old name).
|
||||
_BROWSER_FIELDS = {"genre", "artist", "album_artist", "album"}
|
||||
|
||||
|
||||
def _artist_sort_key(name: str) -> str:
|
||||
"""Sort artists ignoring a leading 'The' (and case): 'The Beatles' files
|
||||
next to 'Beat Happening'."""
|
||||
return _ARTICLE_RE.sub("", name).casefold()
|
||||
|
||||
|
||||
def _canonical_values(values) -> list[str]:
|
||||
"""Collapse case variants of the same string. For each case-insensitive
|
||||
group, return the spelling used by the most tracks (ties: more capital
|
||||
letters, then alphabetical)."""
|
||||
groups: dict[str, Counter] = {}
|
||||
for value in values:
|
||||
groups.setdefault(value.casefold(), Counter())[value] += 1
|
||||
result = []
|
||||
for counter in groups.values():
|
||||
best = sorted(
|
||||
counter.items(),
|
||||
key=lambda kv: (-kv[1], -sum(c.isupper() for c in kv[0]), kv[0]),
|
||||
)[0][0]
|
||||
result.append(best)
|
||||
return result
|
||||
|
||||
|
||||
class _BrowserList(QListWidget):
|
||||
"""One column of the genre/artist/album browser."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(120)
|
||||
self.setUniformItemSizes(True)
|
||||
|
||||
def set_values(self, label: str, values: list[str]):
|
||||
current = self.selected_value()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
self.addItem(f"All ({len(values)} {label})")
|
||||
self.addItems(values)
|
||||
# Keep the previous selection if it survived the refilter
|
||||
row = 0
|
||||
if current:
|
||||
matches = self.findItems(current, Qt.MatchFlag.MatchExactly)
|
||||
if matches:
|
||||
row = self.row(matches[0])
|
||||
self.setCurrentRow(row)
|
||||
self.blockSignals(False)
|
||||
|
||||
def selected_value(self) -> str:
|
||||
item = self.currentItem()
|
||||
if item is None or self.row(item) == 0:
|
||||
return ""
|
||||
return item.text()
|
||||
|
||||
|
||||
class LibraryView(QWidget):
|
||||
"""All-tracks view with the iTunes-style 3-column browser (Ctrl+B)."""
|
||||
|
||||
play_requested = pyqtSignal(list, int)
|
||||
info_requested = pyqtSignal(list)
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Empty strip matching the sidebar's Library button / playlist name
|
||||
# band, so the browser+tracklist top aligns with the playlist tree.
|
||||
self._top_strip = QWidget()
|
||||
layout.addWidget(self._top_strip)
|
||||
|
||||
# Search row: a debounced filter box occupying ~1/3 of the width, top
|
||||
# right (iTunes-style). It narrows the track list and the browser
|
||||
# columns together.
|
||||
self._search_text = ""
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("search")
|
||||
self._search.setClearButtonEnabled(True)
|
||||
search_row = QHBoxLayout()
|
||||
search_row.setContentsMargins(4, 2, 4, 2)
|
||||
search_row.addStretch(2)
|
||||
search_row.addWidget(self._search, 1)
|
||||
layout.addLayout(search_row)
|
||||
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(_SEARCH_DEBOUNCE_MS)
|
||||
self._search_timer.timeout.connect(self._apply_search)
|
||||
self._search.textChanged.connect(lambda _t: self._search_timer.start())
|
||||
|
||||
self._browser = QSplitter(Qt.Orientation.Horizontal)
|
||||
self._genre_list = _BrowserList()
|
||||
self._artist_list = _BrowserList()
|
||||
self._album_list = _BrowserList()
|
||||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||||
self._browser.addWidget(lst)
|
||||
|
||||
self.table = TrackTableView(playlist_mode=False)
|
||||
|
||||
# Vertical splitter so the browser/track-list boundary is draggable
|
||||
self._vsplit = QSplitter(Qt.Orientation.Vertical)
|
||||
self._vsplit.addWidget(self._browser)
|
||||
self._vsplit.addWidget(self.table)
|
||||
self._vsplit.setStretchFactor(1, 1)
|
||||
self._vsplit.setCollapsible(1, False)
|
||||
self._vsplit.setSizes([200, 600])
|
||||
layout.addWidget(self._vsplit)
|
||||
|
||||
self._genre_list.currentItemChanged.connect(self._on_genre_changed)
|
||||
self._artist_list.currentItemChanged.connect(self._on_artist_changed)
|
||||
self._album_list.currentItemChanged.connect(self._on_album_changed)
|
||||
|
||||
self.table.playlists_for_track = manager.playlists_containing
|
||||
self.table.play_requested.connect(self.play_requested)
|
||||
self.table.info_requested.connect(self.info_requested)
|
||||
self.table.show_in_playlist_requested.connect(
|
||||
self.show_in_playlist_requested)
|
||||
self.table.sort_changed.connect(self._on_sort_changed)
|
||||
self.table.columns_changed.connect(self._on_columns_changed)
|
||||
self.table.column_width_changed.connect(self._on_width_changed)
|
||||
|
||||
manager.track_updated.connect(self.table.model_.refresh_track)
|
||||
manager.track_fields_edited.connect(self._on_track_fields_edited)
|
||||
|
||||
self.reload()
|
||||
|
||||
def set_header_height(self, height: int):
|
||||
# Kept in sync with the sidebar Library button and the playlist view's
|
||||
# name strip so the tracklist tops line up across views.
|
||||
self._top_strip.setFixedHeight(height)
|
||||
|
||||
# ---- settings persistence ----
|
||||
|
||||
@property
|
||||
def _settings(self):
|
||||
return self._manager.library.library_settings
|
||||
|
||||
def _on_sort_changed(self, field, ascending):
|
||||
self._settings.sort_column = field
|
||||
self._settings.sort_ascending = ascending
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
def _on_columns_changed(self, columns):
|
||||
self._settings.visible_columns = columns
|
||||
self.table.apply_settings(self._settings)
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
def _on_width_changed(self, field, width):
|
||||
self._settings.column_widths[field] = width
|
||||
self._manager.mark_library_settings_dirty()
|
||||
|
||||
# ---- browser ----
|
||||
|
||||
def toggle_browser(self):
|
||||
self._browser.setVisible(not self._browser.isVisible())
|
||||
|
||||
def reveal_track(self, track_id: int):
|
||||
"""Scroll to and select a track. If a search or the browser filters
|
||||
hide it, clear them to the full library first so the song is always
|
||||
reachable."""
|
||||
if self.table.reveal_track(track_id):
|
||||
return
|
||||
self._search.blockSignals(True)
|
||||
self._search.clear()
|
||||
self._search.blockSignals(False)
|
||||
self._search_text = ""
|
||||
self._search_tracks = self._all_tracks
|
||||
for lst in (self._genre_list, self._artist_list, self._album_list):
|
||||
lst.blockSignals(True)
|
||||
lst.setCurrentRow(0) # "All"
|
||||
lst.blockSignals(False)
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
self.table.reveal_track(track_id)
|
||||
|
||||
def reload(self):
|
||||
"""Rebuild everything from the library (after import or track add)."""
|
||||
self._all_tracks = list(self._manager.library.tracks.values())
|
||||
self._search_tracks = self._compute_search_tracks()
|
||||
self.table.apply_settings(self._settings)
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
# ---- search ----
|
||||
|
||||
def _compute_search_tracks(self) -> list:
|
||||
"""The subset of the library matching the current search (all tokens
|
||||
must appear, as case-folded substrings, in some searched field)."""
|
||||
if not self._search_text:
|
||||
return self._all_tracks
|
||||
tokens = self._search_text.split()
|
||||
return [t for t in self._all_tracks if _matches_search(t, tokens)]
|
||||
|
||||
def _apply_search(self):
|
||||
self._search_text = self._search.text().strip().casefold()
|
||||
self._search_tracks = self._compute_search_tracks()
|
||||
# Rebuild the whole cascade so the genre/artist/album columns reflect
|
||||
# only what the search matched.
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _filtered_tracks(self, up_to: str) -> list:
|
||||
# Compare casefolded: the browser shows one canonical spelling per
|
||||
# group, but tracks may be tagged with any case variant. The cascade
|
||||
# starts from the search-matched set so the browser stays in sync.
|
||||
tracks = self._search_tracks
|
||||
genre = self._genre_list.selected_value().casefold()
|
||||
if genre:
|
||||
tracks = [t for t in tracks if t.genre.casefold() == genre]
|
||||
if up_to in ("artist", "album"):
|
||||
artist = self._artist_list.selected_value().casefold()
|
||||
if artist:
|
||||
tracks = [t for t in tracks
|
||||
if (t.album_artist or t.artist).casefold() == artist]
|
||||
if up_to == "album":
|
||||
album = self._album_list.selected_value().casefold()
|
||||
if album:
|
||||
tracks = [t for t in tracks if t.album.casefold() == album]
|
||||
return tracks
|
||||
|
||||
def _populate_genres(self):
|
||||
genres = _canonical_values(t.genre for t in self._search_tracks if t.genre)
|
||||
self._genre_list.set_values("Genres", sorted(genres, key=str.casefold))
|
||||
|
||||
def _populate_artists(self):
|
||||
tracks = self._filtered_tracks(up_to="genre")
|
||||
artists = _canonical_values((t.album_artist or t.artist) for t in tracks
|
||||
if t.album_artist or t.artist)
|
||||
self._artist_list.set_values("Artists",
|
||||
sorted(artists, key=_artist_sort_key))
|
||||
|
||||
def _populate_albums(self):
|
||||
tracks = self._filtered_tracks(up_to="artist")
|
||||
albums = _canonical_values(t.album for t in tracks if t.album)
|
||||
self._album_list.set_values("Albums", sorted(albums, key=str.casefold))
|
||||
|
||||
def _update_track_table(self):
|
||||
self.table.set_tracks(self._filtered_tracks(up_to="album"))
|
||||
|
||||
def _on_genre_changed(self):
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _on_artist_changed(self):
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
|
||||
def _on_album_changed(self):
|
||||
self._update_track_table()
|
||||
|
||||
def _on_track_fields_edited(self, track_id: int, changed_fields: list):
|
||||
"""A Get-Info edit landed. If it touched a field the browser groups by,
|
||||
rebuild the lists and re-filter so the renamed value sorts to its new
|
||||
spot and clicking it still finds its tracks (the plain row repaint from
|
||||
track_updated leaves the browser stale). Non-browser edits (name, bpm…)
|
||||
are handled by the proxy's live re-sort, so they need nothing here."""
|
||||
if _BROWSER_FIELDS.intersection(changed_fields):
|
||||
self._populate_genres()
|
||||
self._populate_artists()
|
||||
self._populate_albums()
|
||||
self._update_track_table()
|
||||
@@ -0,0 +1,405 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
|
||||
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
|
||||
QLabel, QMessageBox, QFileDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
from PyQt6.QtGui import QAction, QKeySequence
|
||||
|
||||
from lintunes import theme
|
||||
from lintunes.inhibit import SleepInhibitor
|
||||
from lintunes.player import Player
|
||||
from lintunes.importers import file_importer
|
||||
from lintunes.gui.sidebar import SidebarPanel
|
||||
from lintunes.gui.library_view import LibraryView
|
||||
from lintunes.gui.playlist_view import PlaylistView
|
||||
from lintunes.gui.transport import TransportBar
|
||||
from lintunes.gui.info_dialog import InfoDialog
|
||||
from lintunes.gui.preferences_dialog import PreferencesDialog
|
||||
from lintunes.gui.track_table import format_total_time
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, manager, prefs, lastfm=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self._prefs_dialog = None
|
||||
self.player = Player(manager, self)
|
||||
# Keep the machine awake while audio is actually playing.
|
||||
self._inhibitor = SleepInhibitor()
|
||||
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
||||
# the now-playing speaker icon to where the song is actually playing.
|
||||
self._now_playing_context = ""
|
||||
|
||||
self.setWindowTitle("LinTunes")
|
||||
self.resize(1280, 800)
|
||||
# Keep the window's minimum well under typical sizes so the WM never
|
||||
# refuses an interactive shrink
|
||||
self.setMinimumSize(560, 420)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
self._transport = TransportBar(self.player, manager, self._prefs)
|
||||
layout.addWidget(self._transport)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
self._sidebar = SidebarPanel(manager, self.player)
|
||||
self._sidebar.setMinimumWidth(140)
|
||||
splitter.addWidget(self._sidebar)
|
||||
|
||||
self._content = QStackedWidget()
|
||||
splitter.addWidget(self._content)
|
||||
splitter.setSizes([220, 1060])
|
||||
splitter.setCollapsible(1, False)
|
||||
|
||||
self._library_view = LibraryView(manager)
|
||||
self._content.addWidget(self._library_view)
|
||||
self._playlist_view = PlaylistView(manager)
|
||||
self._content.addWidget(self._playlist_view)
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
|
||||
# Bottom status bar: a permanent, centered totals readout (transient
|
||||
# import/scrobble/error messages still use the left showMessage area).
|
||||
self._totals_label = QLabel()
|
||||
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.statusBar().addPermanentWidget(self._totals_label, 1)
|
||||
|
||||
# Wiring
|
||||
self._transport.play_clicked.connect(self.play_pause)
|
||||
self._sidebar.library_selected.connect(self._show_library)
|
||||
self._sidebar.playlist_selected.connect(self._show_playlist)
|
||||
self._content.currentChanged.connect(self._update_totals)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.play_requested.connect(
|
||||
lambda ids, start, v=view: self._play_from_view(v, ids, start))
|
||||
view.info_requested.connect(self._show_info)
|
||||
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
|
||||
view.table.tracks_changed.connect(self._update_totals)
|
||||
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
|
||||
self.player.error_occurred.connect(
|
||||
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
|
||||
self.player.track_missing.connect(self._on_track_missing)
|
||||
self.player.track_changed.connect(self._on_track_changed)
|
||||
self.player.playing_changed.connect(self._update_now_playing_icon)
|
||||
self.player.playing_changed.connect(self._on_playing_changed)
|
||||
manager.track_updated.connect(self._on_track_meta_updated)
|
||||
prefs.changed.connect(self._on_prefs_changed)
|
||||
if lastfm is not None:
|
||||
self.player.track_changed.connect(lastfm.handle_track_started)
|
||||
self.player.track_finished.connect(lastfm.handle_track_finished)
|
||||
lastfm.status_message.connect(
|
||||
lambda msg: self.statusBar().showMessage(msg, 4000))
|
||||
|
||||
self._build_menus()
|
||||
self.apply_ui_metrics()
|
||||
self._update_totals()
|
||||
|
||||
# ---- status bar totals ----
|
||||
|
||||
def _update_totals(self):
|
||||
view = self._content.currentWidget()
|
||||
count, total_ms = view.table.total_stats()
|
||||
if count:
|
||||
noun = "track" if count == 1 else "tracks"
|
||||
self._totals_label.setText(
|
||||
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
||||
else:
|
||||
self._totals_label.setText("")
|
||||
|
||||
# ---- preferences / theming ----
|
||||
|
||||
def _on_prefs_changed(self):
|
||||
theme.apply_theme(QApplication.instance(), self._prefs)
|
||||
self.apply_ui_metrics()
|
||||
self._transport.refresh_theme()
|
||||
|
||||
def apply_ui_metrics(self):
|
||||
metrics = theme.scale_metrics(self._prefs)
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"])
|
||||
self._library_view.set_header_height(metrics["header_strip"])
|
||||
self._playlist_view.set_header_height(metrics["header_strip"])
|
||||
self._sidebar.apply_metrics(metrics["header_strip"])
|
||||
self.statusBar().setMinimumHeight(metrics["status_height"])
|
||||
|
||||
def _show_preferences(self):
|
||||
if self._prefs_dialog is None:
|
||||
self._prefs_dialog = PreferencesDialog(self._prefs, self._lastfm, self)
|
||||
self._prefs_dialog.show()
|
||||
self._prefs_dialog.raise_()
|
||||
self._prefs_dialog.activateWindow()
|
||||
|
||||
def _build_menus(self):
|
||||
bar = self.menuBar()
|
||||
|
||||
file_menu = bar.addMenu("&File")
|
||||
self._add_action(file_menu, "New Playlist", "Ctrl+N",
|
||||
lambda: self._sidebar.tree.create_playlist_interactive())
|
||||
self._add_action(file_menu, "New Playlist Folder", "Ctrl+Shift+N",
|
||||
lambda: self._manager.create_folder("untitled folder"))
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
|
||||
self._open_files_dialog)
|
||||
file_menu.addSeparator()
|
||||
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
|
||||
|
||||
edit_menu = bar.addMenu("&Edit")
|
||||
self._undo_action = self._add_action(
|
||||
edit_menu, "Undo", QKeySequence.StandardKey.Undo,
|
||||
self._manager.undo_stack.undo)
|
||||
self._redo_action = self._add_action(
|
||||
edit_menu, "Redo", QKeySequence.StandardKey.Redo,
|
||||
self._manager.undo_stack.redo)
|
||||
# Accept both the platform default (Ctrl+Y) and Ctrl+Shift+Z for redo.
|
||||
self._redo_action.setShortcuts([
|
||||
QKeySequence(QKeySequence.StandardKey.Redo),
|
||||
QKeySequence("Ctrl+Shift+Z"),
|
||||
])
|
||||
edit_menu.addSeparator()
|
||||
self._add_action(edit_menu, "Preferences…", "Ctrl+,", self._show_preferences)
|
||||
self._manager.undo_stack.changed.connect(self._refresh_undo_actions)
|
||||
self._refresh_undo_actions()
|
||||
|
||||
controls = bar.addMenu("&Controls")
|
||||
self._add_action(controls, "Play/Pause", "", self.play_pause)
|
||||
self._add_action(controls, "Next", "", self.player.next)
|
||||
self._add_action(controls, "Previous", "", self.player.previous)
|
||||
|
||||
view_menu = bar.addMenu("&View")
|
||||
self._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
|
||||
self._library_view.toggle_browser)
|
||||
|
||||
track_menu = bar.addMenu("&Track")
|
||||
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view)
|
||||
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
|
||||
self._go_to_current_song)
|
||||
|
||||
def _add_action(self, menu, text, shortcut, slot):
|
||||
action = QAction(text, self)
|
||||
if shortcut:
|
||||
action.setShortcut(QKeySequence(shortcut))
|
||||
action.triggered.connect(slot)
|
||||
menu.addAction(action)
|
||||
return action
|
||||
|
||||
def _refresh_undo_actions(self):
|
||||
stack = self._manager.undo_stack
|
||||
can_undo = stack.can_undo()
|
||||
can_redo = stack.can_redo()
|
||||
self._undo_action.setEnabled(can_undo)
|
||||
self._redo_action.setEnabled(can_redo)
|
||||
self._undo_action.setText(
|
||||
f"Undo {stack.undo_label()}" if can_undo else "Undo")
|
||||
self._redo_action.setText(
|
||||
f"Redo {stack.redo_label()}" if can_redo else "Redo")
|
||||
|
||||
# ---- view switching ----
|
||||
|
||||
def _show_library(self):
|
||||
self._content.setCurrentWidget(self._library_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _show_playlist(self, persistent_id: str):
|
||||
playlist = self._manager.library.playlists.get(persistent_id)
|
||||
if playlist:
|
||||
self._playlist_view.show_playlist(playlist)
|
||||
self._content.setCurrentWidget(self._playlist_view)
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _go_to_current_song(self):
|
||||
"""Jump to wherever the current song is playing from, select it, and
|
||||
scroll it into view. No-op when nothing is cued."""
|
||||
track = self.player.current_track
|
||||
if track is None:
|
||||
return
|
||||
ctx = self._now_playing_context
|
||||
if ctx == "library":
|
||||
self._show_library()
|
||||
self._sidebar.reflect_library()
|
||||
self._library_view.reveal_track(track.track_id)
|
||||
elif ctx.startswith("playlist:"):
|
||||
pid = ctx.split(":", 1)[1]
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track.track_id)
|
||||
|
||||
def _show_track_in_playlist(self, track_id: int, pid: str):
|
||||
"""Jump to a playlist and highlight the first instance of the track,
|
||||
reusing the Go-to-Current-Song navigation pattern."""
|
||||
if pid in self._manager.library.playlists:
|
||||
self._show_playlist(pid)
|
||||
self._sidebar.reflect_playlist(pid)
|
||||
self._playlist_view.reveal_track(track_id)
|
||||
|
||||
# ---- info dialog ----
|
||||
|
||||
def _show_info(self, track_ids: list[int]):
|
||||
if track_ids:
|
||||
InfoDialog(self._manager, track_ids, self).exec()
|
||||
|
||||
def _info_for_current_view(self):
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.selected_track_ids()
|
||||
self._show_info(ids)
|
||||
|
||||
# ---- file import ----
|
||||
|
||||
def _music_import_dir(self) -> Path:
|
||||
return Path(self._manager.library.music_folder) / "Music"
|
||||
|
||||
def _open_files_dialog(self):
|
||||
paths, _filter = QFileDialog.getOpenFileNames(
|
||||
self, "Add Files to Library", "",
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff)")
|
||||
if paths:
|
||||
self.import_files([Path(p) for p in paths])
|
||||
|
||||
def import_files(self, paths, playlist_pid: str = ""):
|
||||
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
|
||||
imported = file_importer.import_paths(
|
||||
paths, self._music_import_dir(), self._manager, playlist_pid)
|
||||
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
|
||||
if imported:
|
||||
self._library_view.reload()
|
||||
|
||||
def _import_files_to_playlist(self, paths, pid):
|
||||
self.import_files([Path(p) for p in paths], pid)
|
||||
|
||||
# ---- relocating a moved/renamed file ----
|
||||
|
||||
def _existing_ancestor(self, path: str) -> str:
|
||||
"""Deepest existing directory at or above `path`'s parent, so the file
|
||||
picker opens somewhere real even if the exact folder is gone."""
|
||||
if path:
|
||||
p = Path(path).parent
|
||||
while not p.exists() and p != p.parent:
|
||||
p = p.parent
|
||||
if p.exists():
|
||||
return str(p)
|
||||
music = self._music_import_dir()
|
||||
return str(music) if music.exists() else str(Path.home())
|
||||
|
||||
def _on_track_missing(self, track):
|
||||
"""A track's file is gone (moved/renamed outside lintunes). Offer to
|
||||
relocate it manually; on success, repoint the track and resume."""
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("Can't play track")
|
||||
box.setText(f"Can't play “{track.name}”.")
|
||||
box.setInformativeText(
|
||||
f"File not found:\n{track.location or '(no location)'}")
|
||||
locate_btn = box.addButton("Locate File…", QMessageBox.ButtonRole.ActionRole)
|
||||
box.addButton(QMessageBox.StandardButton.Cancel)
|
||||
box.exec()
|
||||
if box.clickedButton() is not locate_btn:
|
||||
return
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self, f"Locate “{track.name}”", self._existing_ancestor(track.location),
|
||||
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff *.aif);;All files (*)")
|
||||
if not chosen:
|
||||
return
|
||||
self._manager.set_track_location(track.track_id, chosen)
|
||||
# Defer the retry so it runs after this modal handler unwinds.
|
||||
QTimer.singleShot(0, self.player.retry_current)
|
||||
|
||||
# ---- window-level drops (import anywhere) ----
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event):
|
||||
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls()
|
||||
if u.isLocalFile()]
|
||||
if paths:
|
||||
self.import_files(paths)
|
||||
event.acceptProposedAction()
|
||||
|
||||
# ---- playback ----
|
||||
|
||||
def play_pause(self):
|
||||
"""Play/pause like iTunes: with nothing cued, start the visible
|
||||
view's tracks (from the selected row, else the top)."""
|
||||
if self.player.current_track is not None:
|
||||
self.player.toggle_play()
|
||||
return
|
||||
view = self._content.currentWidget()
|
||||
ids = view.table.view_order_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
selected = view.table.selectionModel().selectedRows()
|
||||
start = selected[0].row() if selected else 0
|
||||
self._play_from_view(view, ids, start)
|
||||
|
||||
def _play_from_view(self, view, ids, start):
|
||||
"""Start playback, recording which view it came from so the now-playing
|
||||
icon (and Go to Current Song) can find the right context."""
|
||||
self._now_playing_context = view.table.context_id()
|
||||
self.player.play_queue(ids, start)
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
if track is None:
|
||||
self.setWindowTitle("LinTunes")
|
||||
else:
|
||||
self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes")
|
||||
self._update_now_playing_icon()
|
||||
|
||||
def _on_track_meta_updated(self, track_id: int):
|
||||
current = self.player.current_track
|
||||
if current is not None and current.track_id == track_id:
|
||||
self._sidebar.art.refresh()
|
||||
|
||||
def _update_now_playing_icon(self, _playing=None):
|
||||
track = self.player.current_track
|
||||
track_id = track.track_id if track else None
|
||||
playing = self.player.is_playing()
|
||||
# Only the table showing the context playback started from gets the icon,
|
||||
# so the same song appearing in another playlist/library isn't flagged.
|
||||
for view in (self._library_view, self._playlist_view):
|
||||
match = (track_id is not None
|
||||
and view.table.context_id() == self._now_playing_context)
|
||||
view.table.model_.set_now_playing(track_id if match else None,
|
||||
playing)
|
||||
|
||||
# ---- global media keys (spacebar / arrows), per spec detail 8 ----
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Type.KeyPress and self.isActiveWindow():
|
||||
focus = QApplication.focusWidget()
|
||||
if isinstance(focus, (QLineEdit, QPlainTextEdit, QTextEdit,
|
||||
QAbstractSpinBox, QComboBox)):
|
||||
return False
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Space:
|
||||
self.play_pause()
|
||||
return True
|
||||
if key == Qt.Key.Key_Right:
|
||||
self.player.next()
|
||||
return True
|
||||
if key == Qt.Key.Key_Left:
|
||||
self.player.previous()
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _on_playing_changed(self, playing: bool):
|
||||
if playing:
|
||||
self._inhibitor.inhibit()
|
||||
else:
|
||||
self._inhibitor.release()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._inhibitor.release()
|
||||
self._manager.flush()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Shared playlist-add logic with a duplicate-confirmation prompt.
|
||||
|
||||
All GUI paths that add tracks to a playlist (sidebar drop, drop onto an open
|
||||
playlist's track list, paste) route through here so the duplicate guard is
|
||||
consistent for single- and multi-track adds.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
|
||||
def has_duplicates(track_ids, existing) -> bool:
|
||||
"""Whether any of ``track_ids`` is already in ``existing``."""
|
||||
existing = set(existing)
|
||||
return any(tid in existing for tid in track_ids)
|
||||
|
||||
|
||||
def filter_for_add(track_ids, existing, add_duplicates: bool) -> list[int]:
|
||||
"""The ids to add, preserving order: all of them when ``add_duplicates``,
|
||||
otherwise only the ones not already in ``existing``."""
|
||||
if add_duplicates:
|
||||
return list(track_ids)
|
||||
existing = set(existing)
|
||||
return [tid for tid in track_ids if tid not in existing]
|
||||
|
||||
|
||||
def add_tracks_with_dup_check(manager, parent, pid, track_ids,
|
||||
position=None) -> list[int]:
|
||||
"""Add ``track_ids`` to playlist ``pid``, asking first if any already exist.
|
||||
|
||||
Returns the track ids actually added (empty if the user cancelled or there
|
||||
was nothing to add).
|
||||
"""
|
||||
playlist = manager.library.playlists.get(pid)
|
||||
if not playlist or not track_ids:
|
||||
return []
|
||||
|
||||
existing = playlist.track_ids
|
||||
if has_duplicates(track_ids, existing):
|
||||
to_add = _resolve_duplicates(parent, track_ids, existing)
|
||||
else:
|
||||
to_add = list(track_ids)
|
||||
|
||||
if not to_add:
|
||||
return []
|
||||
manager.add_tracks_to_playlist(pid, to_add, position)
|
||||
return to_add
|
||||
|
||||
|
||||
def _resolve_duplicates(parent, track_ids, existing) -> list[int]:
|
||||
"""Ask whether to re-add duplicates; return the resulting ids to add."""
|
||||
box = QMessageBox(parent)
|
||||
box.setIcon(QMessageBox.Icon.Question)
|
||||
box.setWindowTitle("Duplicate songs")
|
||||
box.setText("One or more songs are already in the playlist. "
|
||||
"Add those ones again?")
|
||||
add_again = box.addButton("Yes, add again",
|
||||
QMessageBox.ButtonRole.AcceptRole)
|
||||
skip = box.addButton("Skip duplicates", QMessageBox.ButtonRole.ActionRole)
|
||||
box.setDefaultButton(skip)
|
||||
box.exec()
|
||||
|
||||
clicked = box.clickedButton()
|
||||
if clicked is add_again:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=True)
|
||||
if clicked is skip:
|
||||
return filter_for_add(track_ids, existing, add_duplicates=False)
|
||||
return [] # dialog dismissed (Escape / close) -> add nothing
|
||||
@@ -0,0 +1,143 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from lintunes.gui.track_table import TrackTableView, parse_tracks_mime
|
||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||
|
||||
|
||||
class PlaylistView(QWidget):
|
||||
play_requested = pyqtSignal(list, int)
|
||||
info_requested = pyqtSignal(list)
|
||||
files_dropped = pyqtSignal(list, str) # paths, playlist pid
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._playlist = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._name_label = QLabel()
|
||||
self._name_label.setStyleSheet("font-weight: bold; padding: 0 8px;")
|
||||
self._name_label.setFixedHeight(34)
|
||||
layout.addWidget(self._name_label)
|
||||
|
||||
self.table = TrackTableView(playlist_mode=True)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
self.table.playlists_for_track = manager.playlists_containing
|
||||
self.table.play_requested.connect(self.play_requested)
|
||||
self.table.info_requested.connect(self.info_requested)
|
||||
self.table.show_in_playlist_requested.connect(
|
||||
self.show_in_playlist_requested)
|
||||
self.table.sort_changed.connect(self._on_sort_changed)
|
||||
self.table.columns_changed.connect(self._on_columns_changed)
|
||||
self.table.column_width_changed.connect(self._on_width_changed)
|
||||
self.table.reorder_requested.connect(self._on_reorder)
|
||||
self.table.tracks_dropped.connect(self._on_tracks_dropped)
|
||||
self.table.files_dropped.connect(self._on_files_dropped)
|
||||
self.table.remove_requested.connect(self._on_remove)
|
||||
self.table.paste_requested.connect(self._on_paste)
|
||||
|
||||
manager.playlist_content_changed.connect(self._on_content_changed)
|
||||
manager.track_updated.connect(self.table.model_.refresh_track)
|
||||
manager.playlists_changed.connect(self._on_playlists_changed)
|
||||
|
||||
def set_header_height(self, height: int):
|
||||
# Kept in sync with the sidebar's Library button so the tree's top
|
||||
# aligns with the tracklist's top
|
||||
self._name_label.setFixedHeight(height)
|
||||
|
||||
@property
|
||||
def playlist_id(self) -> str:
|
||||
return self._playlist.persistent_id if self._playlist else ""
|
||||
|
||||
def reveal_track(self, track_id: int):
|
||||
self.table.reveal_track(track_id)
|
||||
|
||||
def show_playlist(self, playlist):
|
||||
self._playlist = playlist
|
||||
self.table.set_source_playlist(playlist.persistent_id)
|
||||
self.table.apply_settings(playlist.settings)
|
||||
self._reload_tracks()
|
||||
|
||||
def _reload_tracks(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
tracks = [self._manager.library.tracks[tid]
|
||||
for tid in self._playlist.track_ids
|
||||
if tid in self._manager.library.tracks]
|
||||
self.table.set_tracks(tracks)
|
||||
# Count/time now live in the bottom status bar, not next to the name.
|
||||
self._name_label.setText(self._playlist.name)
|
||||
|
||||
def _on_content_changed(self, pid):
|
||||
if self._playlist is not None and pid == self._playlist.persistent_id:
|
||||
self._reload_tracks()
|
||||
|
||||
def _on_playlists_changed(self):
|
||||
# Playlist may have been renamed or deleted
|
||||
if self._playlist is None:
|
||||
return
|
||||
if self._playlist.persistent_id not in self._manager.library.playlists:
|
||||
self._playlist = None
|
||||
self.table.set_tracks([])
|
||||
self._name_label.setText("")
|
||||
else:
|
||||
self._reload_tracks()
|
||||
|
||||
# ---- settings persistence ----
|
||||
|
||||
def _on_sort_changed(self, field, ascending):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.sort_column = field
|
||||
self._playlist.settings.sort_ascending = ascending
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_columns_changed(self, columns):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.visible_columns = columns
|
||||
self.table.apply_settings(self._playlist.settings)
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
def _on_width_changed(self, field, width):
|
||||
if self._playlist is None:
|
||||
return
|
||||
self._playlist.settings.column_widths[field] = width
|
||||
self._manager.mark_playlist_settings_dirty(self._playlist.persistent_id)
|
||||
|
||||
# ---- content edits ----
|
||||
|
||||
def _on_reorder(self, rows, dest):
|
||||
if self._playlist is not None:
|
||||
self._manager.move_tracks_in_playlist(
|
||||
self._playlist.persistent_id, rows, dest)
|
||||
|
||||
def _on_tracks_dropped(self, track_ids, drop_row):
|
||||
if self._playlist is not None:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
track_ids, drop_row)
|
||||
|
||||
def _on_files_dropped(self, paths, drop_row):
|
||||
if self._playlist is not None:
|
||||
self.files_dropped.emit(paths, self._playlist.persistent_id)
|
||||
|
||||
def _on_remove(self, rows):
|
||||
if self._playlist is not None:
|
||||
self._manager.remove_tracks_from_playlist(
|
||||
self._playlist.persistent_id, rows)
|
||||
|
||||
def _on_paste(self):
|
||||
if self._playlist is None:
|
||||
return
|
||||
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
||||
if payload:
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
payload.get("track_ids", []))
|
||||
@@ -0,0 +1,154 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
|
||||
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
|
||||
QFormLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPixmap, QColor, QIcon
|
||||
|
||||
from lintunes.theme import HIGHLIGHT_COLORS
|
||||
|
||||
|
||||
_SCALES = ["small", "medium", "large"]
|
||||
|
||||
|
||||
def _swatch(color_hex: str) -> QIcon:
|
||||
pixmap = QPixmap(18, 18)
|
||||
pixmap.fill(QColor(color_hex))
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
class PreferencesDialog(QDialog):
|
||||
def __init__(self, prefs, lastfm, parent=None):
|
||||
super().__init__(parent)
|
||||
self._prefs = prefs
|
||||
self._lastfm = lastfm
|
||||
self.setWindowTitle("Preferences")
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._build_general_group())
|
||||
layout.addWidget(self._build_lastfm_group())
|
||||
layout.addStretch()
|
||||
|
||||
lastfm.login_finished.connect(self._on_login_finished)
|
||||
self._refresh_lastfm_state()
|
||||
|
||||
# ---- General ----
|
||||
|
||||
def _build_general_group(self) -> QGroupBox:
|
||||
group = QGroupBox("General")
|
||||
form = QFormLayout(group)
|
||||
|
||||
# UI size: 3-position slider
|
||||
size_row = QHBoxLayout()
|
||||
self._size_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._size_slider.setRange(0, 2)
|
||||
self._size_slider.setPageStep(1)
|
||||
self._size_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
||||
self._size_slider.setTickInterval(1)
|
||||
self._size_slider.setFixedWidth(160)
|
||||
current_scale = self._prefs.get("ui_scale", "medium")
|
||||
self._size_slider.setValue(
|
||||
_SCALES.index(current_scale) if current_scale in _SCALES else 1)
|
||||
self._size_label = QLabel(current_scale.capitalize())
|
||||
self._size_slider.valueChanged.connect(self._on_size_changed)
|
||||
size_row.addWidget(QLabel("Small"))
|
||||
size_row.addWidget(self._size_slider)
|
||||
size_row.addWidget(QLabel("Large"))
|
||||
size_row.addSpacing(8)
|
||||
size_row.addWidget(self._size_label)
|
||||
size_row.addStretch()
|
||||
form.addRow("UI size:", size_row)
|
||||
|
||||
# Highlight color swatches
|
||||
color_row = QHBoxLayout()
|
||||
self._color_group = QButtonGroup(self)
|
||||
for name, hex_value in HIGHLIGHT_COLORS.items():
|
||||
radio = QRadioButton(name)
|
||||
radio.setIcon(_swatch(hex_value))
|
||||
radio.setChecked(self._prefs.get("highlight") == name)
|
||||
radio.toggled.connect(
|
||||
lambda checked, n=name: checked and self._prefs.set("highlight", n))
|
||||
self._color_group.addButton(radio)
|
||||
color_row.addWidget(radio)
|
||||
color_row.addStretch()
|
||||
form.addRow("Highlight:", color_row)
|
||||
return group
|
||||
|
||||
def _on_size_changed(self, value: int):
|
||||
scale = _SCALES[value]
|
||||
self._size_label.setText(scale.capitalize())
|
||||
self._prefs.set("ui_scale", scale)
|
||||
|
||||
# ---- Last.fm ----
|
||||
|
||||
def _build_lastfm_group(self) -> QGroupBox:
|
||||
group = QGroupBox("Last.fm")
|
||||
form = QFormLayout(group)
|
||||
|
||||
hint = QLabel(
|
||||
'Needs a free API account: '
|
||||
'<a href="https://www.last.fm/api/account/create">'
|
||||
'last.fm/api/account/create</a>')
|
||||
hint.setOpenExternalLinks(True)
|
||||
form.addRow(hint)
|
||||
|
||||
lf = self._prefs.lastfm
|
||||
self._api_key = QLineEdit(lf.get("api_key", ""))
|
||||
self._api_secret = QLineEdit(lf.get("api_secret", ""))
|
||||
self._api_secret.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._username = QLineEdit(lf.get("username", ""))
|
||||
self._password = QLineEdit()
|
||||
self._password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
form.addRow("API key:", self._api_key)
|
||||
form.addRow("Shared secret:", self._api_secret)
|
||||
form.addRow("Username:", self._username)
|
||||
form.addRow("Password:", self._password)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self._login_button = QPushButton("Log in")
|
||||
self._login_button.clicked.connect(self._on_login_clicked)
|
||||
self._status_label = QLabel("")
|
||||
button_row.addWidget(self._login_button)
|
||||
button_row.addWidget(self._status_label)
|
||||
button_row.addStretch()
|
||||
form.addRow("", button_row)
|
||||
|
||||
self._scrobble_check = QCheckBox("Enable scrobbling")
|
||||
self._scrobble_check.setChecked(lf.get("scrobble_enabled", False))
|
||||
self._scrobble_check.toggled.connect(
|
||||
lambda on: self._prefs.update_lastfm(scrobble_enabled=on))
|
||||
form.addRow("", self._scrobble_check)
|
||||
return group
|
||||
|
||||
def _on_login_clicked(self):
|
||||
if self._lastfm.is_logged_in():
|
||||
self._lastfm.logout()
|
||||
self._refresh_lastfm_state()
|
||||
return
|
||||
api_key = self._api_key.text().strip()
|
||||
secret = self._api_secret.text().strip()
|
||||
username = self._username.text().strip()
|
||||
password = self._password.text()
|
||||
if not all((api_key, secret, username, password)):
|
||||
self._status_label.setText("Fill in all four fields first")
|
||||
return
|
||||
self._login_button.setEnabled(False)
|
||||
self._status_label.setText("Logging in…")
|
||||
self._lastfm.login(api_key, secret, username, password)
|
||||
|
||||
def _on_login_finished(self, success: bool, message: str):
|
||||
self._status_label.setText(message)
|
||||
self._login_button.setEnabled(True)
|
||||
if success:
|
||||
self._password.clear()
|
||||
self._refresh_lastfm_state()
|
||||
|
||||
def _refresh_lastfm_state(self):
|
||||
logged_in = self._lastfm.is_logged_in()
|
||||
self._login_button.setText("Log out" if logged_in else "Log in")
|
||||
self._scrobble_check.setEnabled(logged_in)
|
||||
if logged_in and not self._status_label.text():
|
||||
self._status_label.setText(
|
||||
f"Logged in as {self._prefs.lastfm.get('username', '')}")
|
||||
@@ -0,0 +1,409 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
||||
QInputDialog, QMessageBox, QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
||||
|
||||
from lintunes.models import PlaylistType
|
||||
from lintunes import tagging
|
||||
from lintunes.gui.track_table import parse_tracks_mime, TRACKS_MIME
|
||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||
from lintunes.gui.art_window import ArtWindow
|
||||
from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
PID_ROLE = Qt.ItemDataRole.UserRole
|
||||
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist"
|
||||
|
||||
|
||||
class SidebarPanel(QWidget):
|
||||
"""Left panel: Library button on top, playlist tree, album art nestled
|
||||
flush at the bottom."""
|
||||
|
||||
library_selected = pyqtSignal()
|
||||
playlist_selected = pyqtSignal(str)
|
||||
|
||||
def __init__(self, manager, player, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.library_button = QPushButton("Library")
|
||||
self.library_button.setCheckable(True)
|
||||
self.library_button.setChecked(True)
|
||||
self.library_button.setStyleSheet(
|
||||
"QPushButton { text-align: left; padding-left: 10px;"
|
||||
" border: 1px solid palette(mid); border-radius: 4px;"
|
||||
" margin: 2px 4px 2px 8px; background: palette(alternate-base); }"
|
||||
"QPushButton:checked { background: palette(highlight);"
|
||||
" color: palette(highlighted-text); }")
|
||||
layout.addWidget(self.library_button)
|
||||
|
||||
self.tree = PlaylistTree(manager)
|
||||
layout.addWidget(self.tree, stretch=1)
|
||||
|
||||
self.art = SidebarArt(player)
|
||||
layout.addWidget(self.art)
|
||||
|
||||
self.library_button.clicked.connect(self._on_library_clicked)
|
||||
self.tree.playlist_selected.connect(self._on_playlist_selected)
|
||||
|
||||
def _on_library_clicked(self):
|
||||
self.library_button.setChecked(True)
|
||||
self.tree.clearSelection()
|
||||
self.tree.setCurrentItem(None)
|
||||
self.library_selected.emit()
|
||||
|
||||
def _on_playlist_selected(self, pid: str):
|
||||
self.library_button.setChecked(False)
|
||||
self.playlist_selected.emit(pid)
|
||||
|
||||
def current_playlist_id(self) -> str:
|
||||
return self.tree.current_playlist_id()
|
||||
|
||||
def reflect_library(self):
|
||||
"""Mirror 'Library is selected' in the sidebar without re-navigating."""
|
||||
self.library_button.setChecked(True)
|
||||
self.tree.blockSignals(True)
|
||||
self.tree.clearSelection()
|
||||
self.tree.setCurrentItem(None)
|
||||
self.tree.blockSignals(False)
|
||||
|
||||
def reflect_playlist(self, pid: str):
|
||||
"""Mirror 'this playlist is selected' without re-navigating."""
|
||||
self.library_button.setChecked(False)
|
||||
self.tree.select_item(pid)
|
||||
|
||||
def apply_metrics(self, header_strip: int):
|
||||
self.library_button.setFixedHeight(header_strip)
|
||||
|
||||
|
||||
class SidebarArt(QWidget):
|
||||
"""Square album art pinned to the sidebar bottom; gray square when the
|
||||
current track has no embedded art. Double-click opens the big art window."""
|
||||
|
||||
def __init__(self, player, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._track = None
|
||||
self._art_window = ArtWindow()
|
||||
self.setToolTip("Double-click to view full size")
|
||||
player.track_changed.connect(self.set_track)
|
||||
|
||||
def set_track(self, track):
|
||||
self._track = track
|
||||
self._pixmap = None
|
||||
if track is not None and track.location:
|
||||
data = tagging.read_embedded_artwork(track.location)
|
||||
if data:
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(data):
|
||||
self._pixmap = pixmap
|
||||
if self._art_window.isVisible():
|
||||
self._art_window.set_artwork(self._pixmap, self._title())
|
||||
self.update()
|
||||
|
||||
def refresh(self):
|
||||
"""Re-read art for the current track (after tag edits)."""
|
||||
self.set_track(self._track)
|
||||
|
||||
def _title(self) -> str:
|
||||
if self._track is None:
|
||||
return "Album Art"
|
||||
return (" — ".join(p for p in (self._track.artist, self._track.album) if p)
|
||||
or self._track.name)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
# Keep it square: height follows the sidebar's width
|
||||
if self.height() != self.width():
|
||||
self.setFixedHeight(self.width())
|
||||
super().resizeEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if self._pixmap is not None:
|
||||
self._art_window.set_artwork(self._pixmap, self._title())
|
||||
self._art_window.show()
|
||||
self._art_window.raise_()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self.palette().mid().color())
|
||||
if self._pixmap is None or self._pixmap.isNull():
|
||||
return
|
||||
scaled = self._pixmap.scaled(
|
||||
self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
x = (self.width() - scaled.width()) // 2
|
||||
y = (self.height() - scaled.height()) // 2
|
||||
painter.drawPixmap(x, y, scaled)
|
||||
|
||||
|
||||
class PlaylistTree(QTreeWidget):
|
||||
"""The user's playlist folder tree. Mutations go through LibraryManager;
|
||||
the tree rebuilds on its playlists_changed signal."""
|
||||
|
||||
playlist_selected = pyqtSignal(str) # persistent_id
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._drop_target: QTreeWidgetItem | None = None
|
||||
|
||||
self.setHeaderHidden(True)
|
||||
self.setIndentation(14)
|
||||
self.setAcceptDrops(True)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.currentItemChanged.connect(self._on_selection_changed)
|
||||
self.itemChanged.connect(self._on_item_renamed)
|
||||
|
||||
manager.playlists_changed.connect(self.rebuild)
|
||||
self.rebuild()
|
||||
|
||||
# ---- tree construction ----
|
||||
|
||||
def rebuild(self):
|
||||
selected_pid = self.current_playlist_id()
|
||||
# Preserve which folders the user has open across rebuilds. On the very
|
||||
# first build this set is empty, so the app opens fully collapsed.
|
||||
expanded = self._expanded_pids()
|
||||
self.blockSignals(True)
|
||||
self.clear()
|
||||
|
||||
playlists = self._manager.library.playlists
|
||||
children: dict[str, list] = {}
|
||||
for playlist in playlists.values():
|
||||
parent_pid = playlist.parent_persistent_id
|
||||
if parent_pid not in playlists:
|
||||
parent_pid = ""
|
||||
children.setdefault(parent_pid, []).append(playlist)
|
||||
|
||||
def add_level(parent_item, parent_pid):
|
||||
for playlist in sorted(children.get(parent_pid, []),
|
||||
key=lambda p: p.name.lower()):
|
||||
item = QTreeWidgetItem([playlist.name])
|
||||
item.setData(0, PID_ROLE, playlist.persistent_id)
|
||||
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
||||
item.setData(0, KIND_ROLE, "folder" if is_folder else "playlist")
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
|
||||
if parent_item is None:
|
||||
self.addTopLevelItem(item)
|
||||
else:
|
||||
parent_item.addChild(item)
|
||||
if is_folder:
|
||||
add_level(item, playlist.persistent_id)
|
||||
item.setExpanded(playlist.persistent_id in expanded)
|
||||
|
||||
add_level(None, "")
|
||||
self.blockSignals(False)
|
||||
|
||||
if selected_pid:
|
||||
item = self._find_item(selected_pid)
|
||||
if item:
|
||||
self.setCurrentItem(item)
|
||||
|
||||
def _expanded_pids(self) -> set[str]:
|
||||
"""Persistent ids of folders currently expanded in the tree."""
|
||||
result: set[str] = set()
|
||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if item.data(0, KIND_ROLE) == "folder" and item.isExpanded():
|
||||
result.add(item.data(0, PID_ROLE))
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return result
|
||||
|
||||
def _find_item(self, pid: str):
|
||||
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if item.data(0, PID_ROLE) == pid:
|
||||
return item
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return None
|
||||
|
||||
def current_playlist_id(self) -> str:
|
||||
item = self.currentItem()
|
||||
if item is None:
|
||||
return ""
|
||||
return item.data(0, PID_ROLE) or ""
|
||||
|
||||
def select_item(self, pid: str):
|
||||
"""Select a playlist row without emitting the selection signal."""
|
||||
item = self._find_item(pid)
|
||||
if item is not None:
|
||||
self.blockSignals(True)
|
||||
self.setCurrentItem(item)
|
||||
self.blockSignals(False)
|
||||
|
||||
def create_playlist_interactive(self, parent_pid: str = ""):
|
||||
"""Create a playlist, select it, and open inline rename so the user can
|
||||
name it just by typing (Enter commits, Escape keeps "untitled playlist").
|
||||
The manager's playlists_changed signal rebuilds the tree synchronously,
|
||||
so the new item already exists by the time create_playlist() returns."""
|
||||
playlist = self._manager.create_playlist("untitled playlist", parent_pid)
|
||||
item = self._find_item(playlist.persistent_id)
|
||||
if item is not None:
|
||||
self.setCurrentItem(item) # selects + navigates to the new playlist
|
||||
self.editItem(item, 0) # live inline rename; editor selects all
|
||||
|
||||
# ---- selection / rename ----
|
||||
|
||||
def _on_selection_changed(self, current, _previous):
|
||||
if current is None:
|
||||
return
|
||||
if current.data(0, KIND_ROLE) == "playlist":
|
||||
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
||||
|
||||
def _on_item_renamed(self, item, _column):
|
||||
pid = item.data(0, PID_ROLE)
|
||||
if pid:
|
||||
self._manager.rename_playlist(pid, item.text(0).strip())
|
||||
|
||||
# ---- context menu ----
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
item = self.itemAt(pos)
|
||||
kind = item.data(0, KIND_ROLE) if item else ""
|
||||
pid = item.data(0, PID_ROLE) if item else ""
|
||||
# New items land inside the folder under the cursor (or at top level)
|
||||
parent_pid = ""
|
||||
if kind == "folder":
|
||||
parent_pid = pid
|
||||
elif kind == "playlist":
|
||||
playlist = self._manager.library.playlists.get(pid)
|
||||
if playlist:
|
||||
parent_pid = playlist.parent_persistent_id
|
||||
|
||||
menu = QMenu(self)
|
||||
new_playlist = menu.addAction("New Playlist")
|
||||
new_folder = menu.addAction("New Playlist Folder")
|
||||
rename = delete = None
|
||||
if kind in ("playlist", "folder"):
|
||||
menu.addSeparator()
|
||||
rename = menu.addAction("Rename")
|
||||
delete = menu.addAction("Delete")
|
||||
|
||||
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
if chosen is new_playlist:
|
||||
self.create_playlist_interactive(parent_pid)
|
||||
elif chosen is new_folder:
|
||||
name, ok = QInputDialog.getText(self, "New Playlist Folder", "Name:",
|
||||
text="untitled folder")
|
||||
if ok and name.strip():
|
||||
self._manager.create_folder(name.strip(), parent_pid)
|
||||
elif rename is not None and chosen is rename:
|
||||
self.editItem(item, 0)
|
||||
elif delete is not None and chosen is delete:
|
||||
label = "folder (and everything in it)" if kind == "folder" else "playlist"
|
||||
answer = QMessageBox.question(
|
||||
self, "Delete", f'Delete the {label} "{item.text(0)}"?')
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self._manager.delete_playlist(pid)
|
||||
|
||||
# ---- drag and drop ----
|
||||
|
||||
def mimeTypes(self):
|
||||
return [TRACKS_MIME]
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasFormat(TRACKS_MIME) or event.source() is self:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
||||
if event.source() is self:
|
||||
# Moving a playlist into/out of folders
|
||||
self._clear_drop_target()
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
kind = target.data(0, KIND_ROLE) if target else ""
|
||||
if kind in ("folder", "") or target is None:
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
return
|
||||
if not event.mimeData().hasFormat(TRACKS_MIME):
|
||||
self._clear_drop_target()
|
||||
event.ignore()
|
||||
return
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
if target and target.data(0, KIND_ROLE) == "playlist":
|
||||
self._set_drop_target(target)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
self._clear_drop_target()
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self._clear_drop_target()
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def _set_drop_target(self, item):
|
||||
"""Highlight the playlist row a drop would land on (cleared on the way
|
||||
out). Foreground is set too so the name stays legible on the highlight."""
|
||||
if item is self._drop_target:
|
||||
return
|
||||
self._clear_drop_target()
|
||||
self._drop_target = item
|
||||
self._drop_target_bg = item.background(0)
|
||||
self._drop_target_fg = item.foreground(0)
|
||||
item.setBackground(0, QBrush(self.palette().highlight().color()))
|
||||
item.setForeground(0, QBrush(self.palette().highlightedText().color()))
|
||||
|
||||
def _clear_drop_target(self):
|
||||
if self._drop_target is not None:
|
||||
self._drop_target.setBackground(0, self._drop_target_bg)
|
||||
self._drop_target.setForeground(0, self._drop_target_fg)
|
||||
self._drop_target = None
|
||||
|
||||
def dropEvent(self, event):
|
||||
self._clear_drop_target()
|
||||
target = self.itemAt(event.position().toPoint())
|
||||
if event.source() is self:
|
||||
item = self.currentItem()
|
||||
pid = item.data(0, PID_ROLE) if item else ""
|
||||
if not pid:
|
||||
return
|
||||
new_parent = ""
|
||||
if target is not None and target.data(0, KIND_ROLE) == "folder":
|
||||
new_parent = target.data(0, PID_ROLE)
|
||||
self._manager.move_playlist(pid, new_parent)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
payload = parse_tracks_mime(event.mimeData())
|
||||
if payload and target and target.data(0, KIND_ROLE) == "playlist":
|
||||
added = add_tracks_with_dup_check(
|
||||
self._manager, self, target.data(0, PID_ROLE),
|
||||
payload.get("track_ids", []))
|
||||
if added:
|
||||
self._flash_item(target)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _flash_item(self, item):
|
||||
"""Briefly pulse a playlist row's highlight to confirm a drop landed.
|
||||
|
||||
Adding tracks emits playlist_content_changed (not playlists_changed),
|
||||
so the tree isn't rebuilt and the item reference stays valid.
|
||||
"""
|
||||
highlight = QBrush(self.palette().highlight().color())
|
||||
original = item.background(0)
|
||||
|
||||
def on():
|
||||
item.setBackground(0, highlight)
|
||||
|
||||
def off():
|
||||
item.setBackground(0, original)
|
||||
|
||||
on()
|
||||
QTimer.singleShot(150, off)
|
||||
QTimer.singleShot(300, on)
|
||||
QTimer.singleShot(450, off)
|
||||
@@ -0,0 +1,747 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QTableView, QAbstractItemView, QMenu, QApplication,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal,
|
||||
QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor,
|
||||
QFontMetrics, QDesktopServices, QCursor,
|
||||
)
|
||||
from PyQt6.QtDBus import (
|
||||
QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument,
|
||||
)
|
||||
|
||||
from lintunes.models import Track
|
||||
from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
TRACKS_MIME = "application/x-lintunes-tracks"
|
||||
SORT_ROLE = Qt.ItemDataRole.UserRole
|
||||
|
||||
# Manual play-order pseudo-column (playlist views only, always first)
|
||||
INDEX_FIELD = "#"
|
||||
|
||||
# Column definitions: (field_name, display_name, default_width)
|
||||
ALL_COLUMNS = [
|
||||
("name", "Name", 250),
|
||||
("artist", "Artist", 180),
|
||||
("album", "Album", 180),
|
||||
("album_artist", "Album Artist", 180),
|
||||
("genre", "Genre", 120),
|
||||
("total_time", "Time", 60),
|
||||
("year", "Year", 50),
|
||||
("track_number", "Track #", 60),
|
||||
("disc_number", "Disc", 40),
|
||||
("play_count", "Plays", 50),
|
||||
("skip_count", "Skips", 50),
|
||||
("rating", "Rating", 80),
|
||||
("date_added", "Date Added", 140),
|
||||
("date_modified", "Date Modified", 140),
|
||||
("play_date_utc", "Last Played", 140),
|
||||
("skip_date", "Last Skipped", 140),
|
||||
("bit_rate", "Bit Rate", 60),
|
||||
("sample_rate", "Sample Rate", 80),
|
||||
("composer", "Composer", 150),
|
||||
("grouping", "Grouping", 120),
|
||||
("comments", "Comments", 150),
|
||||
("bpm", "BPM", 50),
|
||||
("kind", "Kind", 120),
|
||||
("size", "Size", 70),
|
||||
]
|
||||
|
||||
COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS}
|
||||
|
||||
|
||||
def format_time(ms: int) -> str:
|
||||
if ms <= 0:
|
||||
return ""
|
||||
total_secs = round(ms / 1000)
|
||||
mins, secs = divmod(total_secs, 60)
|
||||
if mins >= 60:
|
||||
hours, mins = divmod(mins, 60)
|
||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||
return f"{mins}:{secs:02d}"
|
||||
|
||||
|
||||
def format_total_time(ms: int) -> str:
|
||||
"""Trimmed DD:HH:MM:SS: drop empty leading units, keep at least M:SS.
|
||||
|
||||
e.g. 14:21, 9:47:33, 24:18:42:07."""
|
||||
total_secs = round(max(0, ms) / 1000)
|
||||
days, rem = divmod(total_secs, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
mins, secs = divmod(rem, 60)
|
||||
parts = [days, hours, mins, secs]
|
||||
while len(parts) > 2 and parts[0] == 0:
|
||||
parts.pop(0)
|
||||
return ":".join(str(p) if i == 0 else f"{p:02d}"
|
||||
for i, p in enumerate(parts))
|
||||
|
||||
|
||||
def _format_rating(rating: int) -> str:
|
||||
if rating <= 0:
|
||||
return ""
|
||||
return "★" * (rating // 20)
|
||||
|
||||
|
||||
def _format_size(size: int) -> str:
|
||||
if size <= 0:
|
||||
return ""
|
||||
mb = size / (1024 * 1024)
|
||||
return f"{mb:.1f} MB"
|
||||
|
||||
|
||||
def _format_date(iso_str: str) -> str:
|
||||
return iso_str[:10] if iso_str else ""
|
||||
|
||||
|
||||
# Now-playing speaker pixmaps, cached per (playing, color)
|
||||
_speaker_cache: dict[tuple[bool, str], QPixmap] = {}
|
||||
|
||||
|
||||
def speaker_pixmap(playing: bool, color: QColor) -> QPixmap:
|
||||
"""16px speaker silhouette; with sound waves when playing."""
|
||||
key = (playing, color.name())
|
||||
cached = _speaker_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF
|
||||
# 19px wide (not 16) so the outer wave arc, which reaches ~x=17.7 with
|
||||
# its pen, isn't clipped on the right. Height stays 16.
|
||||
pixmap = QPixmap(19, 16)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawPolygon(QPolygonF([
|
||||
QPointF(1, 5.5), QPointF(4.5, 5.5), QPointF(8.5, 1.5),
|
||||
QPointF(8.5, 14.5), QPointF(4.5, 10.5), QPointF(1, 10.5),
|
||||
]))
|
||||
if playing:
|
||||
pen = QPen(color, 1.4)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawArc(QRectF(9.5, 5.5, 4, 5), -60 * 16, 120 * 16)
|
||||
painter.drawArc(QRectF(10.5, 3.0, 6.5, 10), -60 * 16, 120 * 16)
|
||||
painter.end()
|
||||
_speaker_cache[key] = pixmap
|
||||
return pixmap
|
||||
|
||||
|
||||
def make_tracks_mime(track_ids: list[int], source_playlist: str = "",
|
||||
rows: list[int] | None = None) -> QMimeData:
|
||||
mime = QMimeData()
|
||||
payload = {"track_ids": track_ids, "source_playlist": source_playlist,
|
||||
"rows": rows or []}
|
||||
mime.setData(TRACKS_MIME, json.dumps(payload).encode())
|
||||
return mime
|
||||
|
||||
|
||||
def parse_tracks_mime(mime: QMimeData) -> dict | None:
|
||||
if not mime.hasFormat(TRACKS_MIME):
|
||||
return None
|
||||
try:
|
||||
return json.loads(bytes(mime.data(TRACKS_MIME)).decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def reveal_paths(paths: list[str]) -> None:
|
||||
"""Select the given files in the desktop file manager.
|
||||
|
||||
Uses the freedesktop org.freedesktop.FileManager1 interface (Nautilus,
|
||||
Dolphin, Nemo, …), which highlights the files in their folder. Falls back
|
||||
to just opening the containing folder if that service is unavailable.
|
||||
"""
|
||||
if not paths:
|
||||
return
|
||||
bus = QDBusConnection.sessionBus()
|
||||
if bus.isConnected():
|
||||
# The URIs must be marshalled as a D-Bus string array ("as"); a plain
|
||||
# Python list goes over as "av" and Nautilus rejects it (InvalidArgs).
|
||||
uris = QDBusArgument()
|
||||
uris.beginArray(QMetaType(QMetaType.Type.QString.value).id())
|
||||
for p in paths:
|
||||
uris.add(QUrl.fromLocalFile(p).toString())
|
||||
uris.endArray()
|
||||
iface = QDBusInterface(
|
||||
"org.freedesktop.FileManager1",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1",
|
||||
bus,
|
||||
)
|
||||
reply = iface.call("ShowItems", uris, "")
|
||||
if reply.type() != QDBusMessage.MessageType.ErrorMessage:
|
||||
return
|
||||
# No FileManager1 service (or it errored): open the folder instead.
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0])))
|
||||
|
||||
|
||||
class TrackTableModel(QAbstractTableModel):
|
||||
def __init__(self, parent=None, with_index_column=False):
|
||||
super().__init__(parent)
|
||||
self._tracks: list[Track] = []
|
||||
self._fields: list[str] = []
|
||||
self._with_index = with_index_column
|
||||
self._now_playing_id: int | None = None
|
||||
self._now_playing_active = False
|
||||
self.set_visible_columns(["name", "artist", "album"])
|
||||
|
||||
def set_now_playing(self, track_id: int | None, playing: bool):
|
||||
old_id = self._now_playing_id
|
||||
self._now_playing_id = track_id
|
||||
self._now_playing_active = playing
|
||||
for refresh_id in {old_id, track_id} - {None}:
|
||||
self.refresh_track(refresh_id)
|
||||
|
||||
@property
|
||||
def fields(self) -> list[str]:
|
||||
return self._fields
|
||||
|
||||
def set_visible_columns(self, columns: list[str]):
|
||||
self.beginResetModel()
|
||||
fields = [c for c in columns if c in COLUMN_MAP]
|
||||
if not fields:
|
||||
fields = ["name"]
|
||||
if self._with_index:
|
||||
fields = [INDEX_FIELD] + fields
|
||||
self._fields = fields
|
||||
self.endResetModel()
|
||||
|
||||
def set_tracks(self, tracks: list[Track]):
|
||||
self.beginResetModel()
|
||||
self._tracks = list(tracks)
|
||||
self.endResetModel()
|
||||
|
||||
def track_at(self, row: int) -> Track:
|
||||
return self._tracks[row]
|
||||
|
||||
@property
|
||||
def tracks(self) -> list[Track]:
|
||||
return self._tracks
|
||||
|
||||
def refresh_track(self, track_id: int):
|
||||
for row, track in enumerate(self._tracks):
|
||||
if track.track_id == track_id:
|
||||
self.dataChanged.emit(self.index(row, 0),
|
||||
self.index(row, self.columnCount() - 1))
|
||||
|
||||
def flags(self, index):
|
||||
# ItemIsDragEnabled is what lets the view initiate track drags
|
||||
return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
||||
| Qt.ItemFlag.ItemIsDragEnabled)
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return 0 if parent.isValid() else len(self._tracks)
|
||||
|
||||
def columnCount(self, parent=QModelIndex()):
|
||||
return 0 if parent.isValid() else len(self._fields)
|
||||
|
||||
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
|
||||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||||
field = self._fields[section]
|
||||
if field == INDEX_FIELD:
|
||||
return "#"
|
||||
return COLUMN_MAP[field][1]
|
||||
return None
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid():
|
||||
return None
|
||||
field = self._fields[index.column()]
|
||||
track = self._tracks[index.row()]
|
||||
|
||||
if field == INDEX_FIELD:
|
||||
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
|
||||
return index.row() + 1
|
||||
return None
|
||||
|
||||
if (role == Qt.ItemDataRole.DecorationRole and field == "name"
|
||||
and track.track_id == self._now_playing_id):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
color = QApplication.palette().text().color()
|
||||
return speaker_pixmap(self._now_playing_active, color)
|
||||
|
||||
value = getattr(track, field, "")
|
||||
if role == SORT_ROLE:
|
||||
if isinstance(value, str):
|
||||
return value.lower()
|
||||
return value if value is not None else 0
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if field == "total_time":
|
||||
return format_time(value)
|
||||
if field == "rating":
|
||||
return _format_rating(value)
|
||||
if field == "size":
|
||||
return _format_size(value)
|
||||
if field in ("date_added", "play_date_utc", "skip_date", "date_modified"):
|
||||
return _format_date(value or "")
|
||||
if isinstance(value, int) and value == 0:
|
||||
return ""
|
||||
return str(value) if value else ""
|
||||
return None
|
||||
|
||||
|
||||
class TrackTableView(QTableView):
|
||||
"""Shared track list for the library and playlist views.
|
||||
|
||||
Owners connect to the signals and apply changes through LibraryManager;
|
||||
the view itself never mutates the library.
|
||||
"""
|
||||
|
||||
play_requested = pyqtSignal(list, int) # ids in view order, start index
|
||||
sort_changed = pyqtSignal(str, bool) # field, ascending
|
||||
columns_changed = pyqtSignal(list) # visible field names
|
||||
column_width_changed = pyqtSignal(str, int)
|
||||
reorder_requested = pyqtSignal(list, int) # manual rows, dest manual row
|
||||
tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None
|
||||
files_dropped = pyqtSignal(list, object) # paths, insert row or None
|
||||
remove_requested = pyqtSignal(list) # manual rows
|
||||
paste_requested = pyqtSignal()
|
||||
info_requested = pyqtSignal(list) # selected track ids
|
||||
tracks_changed = pyqtSignal() # displayed track set changed
|
||||
show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid
|
||||
|
||||
def __init__(self, parent=None, playlist_mode=False):
|
||||
super().__init__(parent)
|
||||
self._playlist_mode = playlist_mode
|
||||
self._source_playlist_id = ""
|
||||
|
||||
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
||||
self.proxy = QSortFilterProxyModel(self)
|
||||
self.proxy.setSourceModel(self.model_)
|
||||
self.proxy.setSortRole(SORT_ROLE)
|
||||
self.setModel(self.proxy)
|
||||
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.setSortingEnabled(True)
|
||||
self.setShowGrid(False)
|
||||
self.verticalHeader().setVisible(False)
|
||||
self.verticalHeader().setDefaultSectionSize(22)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem)
|
||||
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionsMovable(True)
|
||||
header.setStretchLastSection(True)
|
||||
# Don't bold header labels when a row/cell becomes current (playback
|
||||
# sets the current index, which would otherwise bold every section).
|
||||
header.setHighlightSections(False)
|
||||
header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
header.customContextMenuRequested.connect(self._show_header_menu)
|
||||
header.sortIndicatorChanged.connect(self._on_sort_indicator)
|
||||
header.sectionResized.connect(self._on_section_resized)
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.doubleClicked.connect(self._on_double_click)
|
||||
|
||||
# Set by the owning view (which holds the manager) so the right-click
|
||||
# menu can list the playlists a track belongs to without the view
|
||||
# itself reaching into the library.
|
||||
self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]]
|
||||
|
||||
# Drag and drop (default indicator off: we paint a glowing line)
|
||||
self.setDragEnabled(True)
|
||||
self.setAcceptDrops(True)
|
||||
self.setDropIndicatorShown(False)
|
||||
self._drop_indicator_y: int | None = None
|
||||
|
||||
QShortcut(QKeySequence.StandardKey.Copy, self,
|
||||
context=Qt.ShortcutContext.WidgetShortcut,
|
||||
activated=self.copy_selection)
|
||||
QShortcut(QKeySequence.StandardKey.Paste, self,
|
||||
context=Qt.ShortcutContext.WidgetShortcut,
|
||||
activated=self.paste_requested)
|
||||
|
||||
self._suppress_signals = False
|
||||
|
||||
# ---- configuration ----
|
||||
|
||||
def set_source_playlist(self, pid: str):
|
||||
self._source_playlist_id = pid
|
||||
|
||||
def context_id(self) -> str:
|
||||
"""Identifies what this table is currently showing, so the now-playing
|
||||
icon can be scoped to the context playback started from."""
|
||||
if self._playlist_mode:
|
||||
return f"playlist:{self._source_playlist_id}"
|
||||
return "library"
|
||||
|
||||
def reveal_track(self, track_id: int) -> bool:
|
||||
"""Scroll to and select the row for ``track_id``; False if not shown."""
|
||||
for src_row, track in enumerate(self.model_.tracks):
|
||||
if track.track_id == track_id:
|
||||
proxy_index = self.proxy.mapFromSource(
|
||||
self.model_.index(src_row, 0))
|
||||
self.setCurrentIndex(proxy_index)
|
||||
self.selectRow(proxy_index.row())
|
||||
self.scrollTo(
|
||||
proxy_index,
|
||||
QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_tracks(self, tracks: list[Track]):
|
||||
self.model_.set_tracks(tracks)
|
||||
self.tracks_changed.emit()
|
||||
|
||||
def total_stats(self) -> tuple[int, int]:
|
||||
"""(track count, summed total_time in ms) of the displayed tracks."""
|
||||
tracks = self.model_.tracks
|
||||
return len(tracks), sum(t.total_time for t in tracks)
|
||||
|
||||
def apply_settings(self, settings):
|
||||
"""Apply a PlaylistSettings: columns, widths, sort."""
|
||||
self._suppress_signals = True
|
||||
try:
|
||||
self.model_.set_visible_columns(settings.visible_columns)
|
||||
self._apply_default_widths()
|
||||
for field, width in settings.column_widths.items():
|
||||
if field in self.model_.fields:
|
||||
self.setColumnWidth(self.model_.fields.index(field), width)
|
||||
sort_field = settings.sort_column
|
||||
if sort_field not in self.model_.fields:
|
||||
sort_field = self.model_.fields[0]
|
||||
order = (Qt.SortOrder.AscendingOrder if settings.sort_ascending
|
||||
else Qt.SortOrder.DescendingOrder)
|
||||
self.sortByColumn(self.model_.fields.index(sort_field), order)
|
||||
finally:
|
||||
self._suppress_signals = False
|
||||
|
||||
def _apply_default_widths(self):
|
||||
for i, field in enumerate(self.model_.fields):
|
||||
if field == INDEX_FIELD:
|
||||
self.setColumnWidth(i, 50)
|
||||
else:
|
||||
self.setColumnWidth(i, COLUMN_MAP[field][2])
|
||||
|
||||
# ---- selection / ordering helpers ----
|
||||
|
||||
def view_order_track_ids(self) -> list[int]:
|
||||
ids = []
|
||||
for proxy_row in range(self.proxy.rowCount()):
|
||||
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
|
||||
ids.append(self.model_.track_at(source_row).track_id)
|
||||
return ids
|
||||
|
||||
def selected_source_rows(self) -> list[int]:
|
||||
rows = {self.proxy.mapToSource(idx).row()
|
||||
for idx in self.selectionModel().selectedRows()}
|
||||
return sorted(rows)
|
||||
|
||||
def selected_track_ids(self) -> list[int]:
|
||||
return [self.model_.track_at(r).track_id for r in self.selected_source_rows()]
|
||||
|
||||
def selected_locations(self) -> list[str]:
|
||||
"""File paths of the selected tracks, skipping any without a location."""
|
||||
locs = [self.model_.track_at(r).location
|
||||
for r in self.selected_source_rows()]
|
||||
return [loc for loc in locs if loc]
|
||||
|
||||
def is_manual_sort(self) -> bool:
|
||||
header = self.horizontalHeader()
|
||||
return (self._playlist_mode
|
||||
and self.model_.fields
|
||||
and self.model_.fields[header.sortIndicatorSection()] == INDEX_FIELD
|
||||
and header.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder)
|
||||
|
||||
# ---- copy / paste ----
|
||||
|
||||
def copy_selection(self):
|
||||
ids = self.selected_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
mime = make_tracks_mime(ids, self._source_playlist_id,
|
||||
self.selected_source_rows())
|
||||
QApplication.clipboard().setMimeData(mime)
|
||||
|
||||
# ---- header interactions ----
|
||||
|
||||
def _on_sort_indicator(self, section, order):
|
||||
if self._suppress_signals or not self.model_.fields:
|
||||
return
|
||||
field = self.model_.fields[section]
|
||||
self.sort_changed.emit(field, order == Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def _on_section_resized(self, section, _old, new_width):
|
||||
if self._suppress_signals or section >= len(self.model_.fields):
|
||||
return
|
||||
field = self.model_.fields[section]
|
||||
if field != INDEX_FIELD:
|
||||
self.column_width_changed.emit(field, new_width)
|
||||
|
||||
def _show_header_menu(self, pos):
|
||||
menu = QMenu(self)
|
||||
visible = [f for f in self.model_.fields if f != INDEX_FIELD]
|
||||
for field, label, _w in ALL_COLUMNS:
|
||||
action = menu.addAction(label)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(field in visible)
|
||||
action.setData(field)
|
||||
chosen = menu.exec(self.horizontalHeader().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
field = chosen.data()
|
||||
if chosen.isChecked():
|
||||
new_visible = visible + [field]
|
||||
# Preserve canonical column ordering
|
||||
new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible]
|
||||
else:
|
||||
new_visible = [f for f in visible if f != field]
|
||||
if not new_visible:
|
||||
return
|
||||
self.columns_changed.emit(new_visible)
|
||||
|
||||
# ---- context menu ----
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
if not self.selected_track_ids():
|
||||
return
|
||||
menu = QMenu(self)
|
||||
info_action = menu.addAction("Get Info\tCtrl+I")
|
||||
copy_action = menu.addAction("Copy\tCtrl+C")
|
||||
|
||||
locations = self.selected_locations()
|
||||
reveal_action = copy_path_action = None
|
||||
if locations:
|
||||
menu.addSeparator()
|
||||
reveal_action = menu.addAction("Reveal in File Browser")
|
||||
copy_path_action = menu.addAction("Copy File Path")
|
||||
|
||||
# "Show in Playlist" — only for a single track, listing the regular
|
||||
# playlists it belongs to. Hovering reveals the submenu; choosing an
|
||||
# entry jumps to that playlist and highlights the first instance.
|
||||
show_in_menu = None
|
||||
selected = self.selected_track_ids()
|
||||
if len(selected) == 1 and self.playlists_for_track is not None:
|
||||
entries = self.playlists_for_track(selected[0])
|
||||
if entries:
|
||||
menu.addSeparator()
|
||||
show_in_menu = menu.addMenu("Show in Playlist")
|
||||
for pid, name in entries:
|
||||
show_in_menu.addAction(name).setData(pid)
|
||||
|
||||
remove_action = None
|
||||
if self._playlist_mode:
|
||||
menu.addSeparator()
|
||||
remove_action = menu.addAction("Remove from Playlist\tDel")
|
||||
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
||||
if chosen is None:
|
||||
return
|
||||
if chosen is info_action:
|
||||
self.info_requested.emit(self.selected_track_ids())
|
||||
elif chosen is copy_action:
|
||||
self.copy_selection()
|
||||
elif chosen is reveal_action:
|
||||
reveal_paths(locations)
|
||||
elif chosen is copy_path_action:
|
||||
QApplication.clipboard().setText("\n".join(locations))
|
||||
elif show_in_menu is not None and chosen in show_in_menu.actions():
|
||||
self.show_in_playlist_requested.emit(selected[0], chosen.data())
|
||||
elif remove_action is not None and chosen is remove_action:
|
||||
self.remove_requested.emit(self.selected_source_rows())
|
||||
|
||||
# ---- playback ----
|
||||
|
||||
def _on_double_click(self, index):
|
||||
ids = self.view_order_track_ids()
|
||||
if ids:
|
||||
self.play_requested.emit(ids, index.row())
|
||||
|
||||
# ---- keyboard ----
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace)
|
||||
and self._playlist_mode and self.selected_source_rows()):
|
||||
self.remove_requested.emit(self.selected_source_rows())
|
||||
return
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
current = self.currentIndex()
|
||||
if current.isValid():
|
||||
self._on_double_click(current)
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# ---- drag and drop ----
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
ids = self.selected_track_ids()
|
||||
if not ids:
|
||||
return
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(make_tracks_mime(ids, self._source_playlist_id,
|
||||
self.selected_source_rows()))
|
||||
# Mutter/Wayland won't render QDrag.setPixmap, so we drive our own
|
||||
# cursor-following overlay (drag_ghost) instead.
|
||||
pixmap, hotspot = self._drag_pixmap(ids)
|
||||
drag_ghost.begin(self.window(), pixmap, hotspot, QCursor.pos())
|
||||
try:
|
||||
drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction)
|
||||
finally:
|
||||
drag_ghost.end()
|
||||
|
||||
def _drag_pixmap(self, ids: list[int]) -> tuple[QPixmap, QPoint]:
|
||||
"""A song-file icon plus a rounded chip naming the dragged track(s).
|
||||
|
||||
Returns the composed pixmap and the hotspot (the icon's centre) so the
|
||||
icon sits directly under the cursor with the name chip trailing right.
|
||||
"""
|
||||
name = self.model_.track_at(self.selected_source_rows()[0]).name
|
||||
text = name or "(untitled)"
|
||||
if len(ids) > 1:
|
||||
text += f" +{len(ids) - 1} more"
|
||||
metrics = QFontMetrics(self.font())
|
||||
text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, 260)
|
||||
pad_x = 10
|
||||
chip_w = metrics.horizontalAdvance(text) + 2 * pad_x
|
||||
h = metrics.height() + 10
|
||||
icon_size = h
|
||||
gap = 6
|
||||
w = icon_size + gap + chip_w
|
||||
ratio = self.devicePixelRatioF()
|
||||
pixmap = QPixmap(int(w * ratio), int(h * ratio))
|
||||
pixmap.setDevicePixelRatio(ratio)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.drawPixmap(0, 0, drag_ghost.song_icon(icon_size, ratio))
|
||||
chip_x = icon_size + gap
|
||||
background = QColor(self.palette().highlight().color())
|
||||
background.setAlpha(230)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(background)
|
||||
painter.drawRoundedRect(QRectF(chip_x, 0, chip_w, h), h / 2, h / 2)
|
||||
painter.setPen(self.palette().highlightedText().color())
|
||||
painter.setFont(self.font())
|
||||
painter.drawText(QRectF(chip_x + pad_x, 0, chip_w - 2 * pad_x, h),
|
||||
Qt.AlignmentFlag.AlignVCenter, text)
|
||||
painter.end()
|
||||
return pixmap, QPoint(icon_size // 2, h // 2)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasFormat(TRACKS_MIME) or event.mimeData().hasUrls():
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
||||
mime = event.mimeData()
|
||||
accepted = False
|
||||
if mime.hasUrls():
|
||||
accepted = True
|
||||
else:
|
||||
payload = parse_tracks_mime(mime)
|
||||
if payload is None:
|
||||
accepted = False
|
||||
elif (self._playlist_mode and self._source_playlist_id
|
||||
and payload.get("source_playlist") == self._source_playlist_id):
|
||||
# reorder only allowed in manual order
|
||||
accepted = self.is_manual_sort()
|
||||
else:
|
||||
# library view is not a drop target for tracks
|
||||
accepted = self._playlist_mode
|
||||
if accepted:
|
||||
event.acceptProposedAction()
|
||||
self._set_drop_indicator(
|
||||
self._indicator_y(event.position().toPoint()))
|
||||
else:
|
||||
event.ignore()
|
||||
self._set_drop_indicator(None)
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self._set_drop_indicator(None)
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def dropEvent(self, event):
|
||||
self._set_drop_indicator(None)
|
||||
mime = event.mimeData()
|
||||
drop_row = self._drop_row(event.position().toPoint())
|
||||
if mime.hasUrls():
|
||||
paths = [u.toLocalFile() for u in mime.urls() if u.isLocalFile()]
|
||||
if paths:
|
||||
self.files_dropped.emit(paths, drop_row)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
payload = parse_tracks_mime(mime)
|
||||
if payload is None or not self._playlist_mode:
|
||||
return
|
||||
same_playlist = (self._source_playlist_id
|
||||
and payload.get("source_playlist") == self._source_playlist_id)
|
||||
if same_playlist:
|
||||
if self.is_manual_sort():
|
||||
dest = drop_row if drop_row is not None else self.model_.rowCount()
|
||||
self.reorder_requested.emit(payload.get("rows", []), dest)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
self.tracks_dropped.emit(payload.get("track_ids", []), drop_row)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _drop_row(self, pos) -> int | None:
|
||||
"""Manual-order row to insert before, or None to append.
|
||||
|
||||
Only meaningful when the view order equals manual order; otherwise
|
||||
callers should append.
|
||||
"""
|
||||
index = self.indexAt(pos)
|
||||
if not index.isValid():
|
||||
return None
|
||||
if not self.is_manual_sort():
|
||||
return None
|
||||
rect = self.visualRect(index)
|
||||
row = index.row()
|
||||
if pos.y() > rect.center().y():
|
||||
row += 1
|
||||
return row
|
||||
|
||||
# ---- drop indicator ----
|
||||
|
||||
def _indicator_y(self, pos) -> int:
|
||||
"""Viewport y for the insertion line matching where the drop lands."""
|
||||
count = self.proxy.rowCount()
|
||||
if count == 0:
|
||||
return 0
|
||||
row = self._drop_row(pos)
|
||||
if row is None: # drop appends
|
||||
row = count
|
||||
if row < count:
|
||||
return self.visualRect(self.proxy.index(row, 0)).top()
|
||||
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
|
||||
|
||||
def _set_drop_indicator(self, y: int | None):
|
||||
if y != self._drop_indicator_y:
|
||||
self._drop_indicator_y = y
|
||||
self.viewport().update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
if self._drop_indicator_y is None:
|
||||
return
|
||||
y = max(1, min(self._drop_indicator_y, self.viewport().height() - 2))
|
||||
right = self.viewport().width() - 4
|
||||
painter = QPainter(self.viewport())
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor(self.palette().highlight().color())
|
||||
glow = QColor(color)
|
||||
glow.setAlpha(70)
|
||||
painter.setPen(QPen(glow, 6, Qt.PenStyle.SolidLine,
|
||||
Qt.PenCapStyle.RoundCap))
|
||||
painter.drawLine(4, y, right, y)
|
||||
painter.setPen(QPen(color, 2))
|
||||
painter.drawLine(4, y, right, y)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawEllipse(QPointF(4, y), 3, 3)
|
||||
painter.drawEllipse(QPointF(right, y), 3, 3)
|
||||
@@ -0,0 +1,360 @@
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSlider,
|
||||
QPushButton, QSizePolicy, QStyle,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QColor, QFont
|
||||
|
||||
from lintunes.tap_tempo import TapTempo
|
||||
from lintunes.gui.icons import transport_icon
|
||||
from lintunes.gui.track_table import format_time
|
||||
from lintunes.gui.visualizer import VisualizerWidget
|
||||
from lintunes import theme
|
||||
|
||||
|
||||
ICON_GRAY = QColor("#4A4A4A")
|
||||
|
||||
# Height floor for the top control panel. It must exceed the natural stacked
|
||||
# height of title + artist + timeline (~69px at medium scale) so the leftover
|
||||
# becomes vertical slack that the equal stretches split into matching borders
|
||||
# above the title and below the artist. Bigger = more breathing room (and a
|
||||
# taller bar); the text block stays centered either way.
|
||||
BAR_HEIGHT = 84
|
||||
|
||||
# Shared height for the boxed side controls (transport, shuffle, bpm) and the
|
||||
# visualizer, so they line up. ~midway between their old heights: the stretchy
|
||||
# boxes/visualizer used to fill the bar (~78px) while the hugging bpm box sat
|
||||
# at ~46px. Tune to taste.
|
||||
CONTROL_HEIGHT = 62
|
||||
|
||||
_BOX_STYLE = """
|
||||
QFrame#transportBox {
|
||||
border: 1px solid palette(mid);
|
||||
border-radius: 8px;
|
||||
background: palette(alternate-base);
|
||||
}
|
||||
"""
|
||||
|
||||
_NOWPLAYING_STYLE = """
|
||||
QFrame#nowPlayingBox {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
"""
|
||||
|
||||
# Title sits this many points above the artist/album line, both following the
|
||||
# UI-scale app font (see theme.UI_SCALES).
|
||||
TITLE_PT_BUMP = 2
|
||||
|
||||
|
||||
class FlashButton(QToolButton):
|
||||
"""Transport button: dark-gray glyph that shows the highlight color
|
||||
while pressed (a quick flash on click, sustained while held). A checkable
|
||||
button (shuffle) keeps the highlight color while checked."""
|
||||
|
||||
def __init__(self, kind: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._kind = kind
|
||||
self.setAutoRaise(True)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.pressed.connect(lambda: self._apply_icon(active=True))
|
||||
self.released.connect(lambda: self._apply_icon())
|
||||
self.toggled.connect(lambda _: self._apply_icon())
|
||||
self.refresh_icons()
|
||||
|
||||
def set_kind(self, kind: str):
|
||||
if kind != self._kind:
|
||||
self._kind = kind
|
||||
self._apply_icon(active=self.isDown())
|
||||
|
||||
def refresh_icons(self):
|
||||
self._apply_icon(active=self.isDown())
|
||||
|
||||
def _apply_icon(self, active: bool = False):
|
||||
show_active = active or (self.isCheckable() and self.isChecked())
|
||||
color = self.palette().highlight().color() if show_active else ICON_GRAY
|
||||
self.setIcon(transport_icon(self._kind, color))
|
||||
|
||||
|
||||
def _box(*widgets, margins=(10, 3, 10, 3), hug=False, height=None) -> QFrame:
|
||||
"""A small rounded frame with its content centered inside.
|
||||
|
||||
With hug=True the frame stays at its size hint instead of stretching
|
||||
to fill the row it sits in. height pins the frame's height (width is
|
||||
left untouched), so several boxes can be lined up to the same height."""
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transportBox")
|
||||
frame.setStyleSheet(_BOX_STYLE)
|
||||
inner = QHBoxLayout(frame)
|
||||
inner.setContentsMargins(*margins)
|
||||
inner.addStretch()
|
||||
for widget in widgets:
|
||||
inner.addWidget(widget)
|
||||
inner.addStretch()
|
||||
if height is not None:
|
||||
frame.setFixedHeight(height)
|
||||
if hug:
|
||||
frame.setSizePolicy(QSizePolicy.Policy.Maximum,
|
||||
QSizePolicy.Policy.Maximum)
|
||||
return frame
|
||||
|
||||
|
||||
class SeekSlider(QSlider):
|
||||
"""Timeline slider: clicking anywhere jumps the handle (and playhead)
|
||||
straight to the clicked spot instead of paging; dragging scrubs."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(Qt.Orientation.Horizontal, parent)
|
||||
|
||||
def _value_at(self, x: float) -> int:
|
||||
return QStyle.sliderValueFromPosition(
|
||||
self.minimum(), self.maximum(), int(x), self.width())
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > 0:
|
||||
self.setSliderDown(True) # emits sliderPressed
|
||||
self.setSliderPosition(self._value_at(event.position().x()))
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self.isSliderDown():
|
||||
self.setSliderPosition(self._value_at(event.position().x()))
|
||||
event.accept()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.isSliderDown():
|
||||
self.setSliderDown(False) # emits sliderReleased
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class BpmButton(QPushButton):
|
||||
"""Tap-tempo button: tap along to the music; when the cursor leaves,
|
||||
the shown bpm is saved to the playing track 3 seconds later."""
|
||||
|
||||
SAVE_DELAY_MS = 3000
|
||||
|
||||
def __init__(self, player, manager, parent=None):
|
||||
super().__init__("bpm", parent)
|
||||
self._player = player
|
||||
self._manager = manager
|
||||
self._tempo = TapTempo()
|
||||
self._target_track_id: int | None = None
|
||||
self.setFixedWidth(64)
|
||||
self.setFlat(True) # flat text inside its box, like the transport icons
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setEnabled(False)
|
||||
self.setToolTip("Tap along to set this track's BPM")
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(self.SAVE_DELAY_MS)
|
||||
self._save_timer.timeout.connect(self._save_bpm)
|
||||
|
||||
self.clicked.connect(self._on_tap)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
# Track switched: any in-progress tap series no longer applies
|
||||
self._reset()
|
||||
self.setEnabled(track is not None)
|
||||
|
||||
def _on_tap(self):
|
||||
self._save_timer.stop()
|
||||
track = self._player.current_track
|
||||
if track is None:
|
||||
return
|
||||
if self._tempo.tap_count == 0:
|
||||
self._target_track_id = track.track_id
|
||||
bpm = self._tempo.tap(time.monotonic())
|
||||
self.setText(str(bpm) if bpm is not None else "...")
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._tempo.tap_count >= 2 and self._tempo.bpm() is not None:
|
||||
self._save_timer.start()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _save_bpm(self):
|
||||
bpm = self._tempo.bpm()
|
||||
track = (self._manager.library.tracks.get(self._target_track_id)
|
||||
if self._target_track_id is not None else None)
|
||||
if bpm is not None and track is not None:
|
||||
# Writes the bpm tag to the file and records it for undo.
|
||||
self._manager.edit_track_fields(track.track_id, {"bpm": bpm})
|
||||
self._reset()
|
||||
|
||||
def _reset(self):
|
||||
self._save_timer.stop()
|
||||
self._tempo.reset()
|
||||
self._target_track_id = None
|
||||
self.setText("bpm")
|
||||
|
||||
|
||||
class TransportBar(QWidget):
|
||||
"""Top bar: boxed transport buttons and shuffle (left), visualizer,
|
||||
now-playing text, seek slider, boxed bpm tap button (right)."""
|
||||
|
||||
play_clicked = pyqtSignal() # MainWindow decides what "play" means
|
||||
|
||||
def __init__(self, player, manager, prefs, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._prefs = prefs
|
||||
self._scrubbing = False
|
||||
self._current_track = None
|
||||
|
||||
self.setMinimumHeight(BAR_HEIGHT)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 3, 8, 3)
|
||||
|
||||
self._prev_btn = FlashButton("previous")
|
||||
self._play_btn = FlashButton("play")
|
||||
self._next_btn = FlashButton("next")
|
||||
layout.addWidget(_box(self._prev_btn, self._play_btn, self._next_btn,
|
||||
height=CONTROL_HEIGHT))
|
||||
|
||||
self._shuffle_btn = FlashButton("shuffle")
|
||||
self._shuffle_btn.setCheckable(True)
|
||||
self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)")
|
||||
layout.addWidget(_box(self._shuffle_btn, height=CONTROL_HEIGHT))
|
||||
|
||||
layout.addSpacing(10)
|
||||
self._visualizer = VisualizerWidget(player)
|
||||
self._visualizer.setFixedHeight(CONTROL_HEIGHT)
|
||||
layout.addWidget(self._visualizer)
|
||||
layout.addSpacing(10)
|
||||
|
||||
center = QVBoxLayout()
|
||||
center.setSpacing(0)
|
||||
|
||||
# White rounded now-playing panel: holds only the song title and the
|
||||
# artist/album line, centered, filling the zone above the seek row.
|
||||
self._now_playing = QFrame()
|
||||
self._now_playing.setObjectName("nowPlayingBox")
|
||||
self._now_playing.setStyleSheet(_NOWPLAYING_STYLE)
|
||||
np = QVBoxLayout(self._now_playing)
|
||||
np.setContentsMargins(12, 6, 12, 6)
|
||||
np.setSpacing(2)
|
||||
np.addStretch(1)
|
||||
|
||||
self._title_label = QLabel("")
|
||||
self._title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
np.addWidget(self._title_label)
|
||||
|
||||
self._artist_label = QLabel("")
|
||||
self._artist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
np.addWidget(self._artist_label)
|
||||
|
||||
np.addStretch(1)
|
||||
self._apply_now_playing_font()
|
||||
|
||||
seek_row = QHBoxLayout()
|
||||
self._elapsed_label = QLabel("")
|
||||
self._elapsed_label.setFixedWidth(50)
|
||||
self._remaining_label = QLabel("")
|
||||
self._remaining_label.setFixedWidth(50)
|
||||
self._remaining_label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self._slider = SeekSlider()
|
||||
self._slider.setRange(0, 0)
|
||||
self._slider.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
seek_row.addWidget(self._elapsed_label)
|
||||
seek_row.addWidget(self._slider)
|
||||
seek_row.addWidget(self._remaining_label)
|
||||
|
||||
# The now-playing panel fills the zone above the seek row; the seek row
|
||||
# stays pinned to the bottom so the timeline sits low. Its height is
|
||||
# just the slider + time labels, so the panel takes the rest.
|
||||
center.addWidget(self._now_playing, stretch=1)
|
||||
center.addLayout(seek_row)
|
||||
layout.addLayout(center, stretch=1)
|
||||
|
||||
# bpm tap button lives on the far right as its own box, vertically
|
||||
# centered like the transport boxes — kept out of the seek row so it
|
||||
# doesn't drive the timeline row's height.
|
||||
self._bpm_button = BpmButton(player, manager)
|
||||
layout.addWidget(_box(self._bpm_button, margins=(8, 8, 8, 8), hug=True,
|
||||
height=CONTROL_HEIGHT))
|
||||
|
||||
# Wiring
|
||||
self._prev_btn.clicked.connect(player.previous)
|
||||
self._play_btn.clicked.connect(self.play_clicked)
|
||||
self._next_btn.clicked.connect(player.next)
|
||||
self._shuffle_btn.toggled.connect(player.set_shuffle)
|
||||
self._slider.sliderPressed.connect(self._on_scrub_start)
|
||||
self._slider.sliderReleased.connect(self._on_scrub_end)
|
||||
self._slider.sliderMoved.connect(self._on_scrub_move)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
player.playing_changed.connect(self._on_playing_changed)
|
||||
player.position_changed.connect(self._on_position_changed)
|
||||
player.duration_changed.connect(self._on_duration_changed)
|
||||
|
||||
def refresh_theme(self):
|
||||
"""Rebuild button glyphs after a highlight-color or font change."""
|
||||
for button in (self._prev_btn, self._play_btn, self._next_btn,
|
||||
self._shuffle_btn):
|
||||
button.refresh_icons()
|
||||
self._apply_now_playing_font()
|
||||
|
||||
def _apply_now_playing_font(self):
|
||||
"""Set the now-playing labels to Century Gothic (or the user's chosen
|
||||
substitute), black text; title slightly larger than artist/album.
|
||||
Sizes track the app font so they follow the UI-scale preference."""
|
||||
family = theme.now_playing_font_family(self._prefs)
|
||||
base_pt = self.font().pointSize()
|
||||
for label, delta in ((self._title_label, TITLE_PT_BUMP),
|
||||
(self._artist_label, 0)):
|
||||
font = QFont(family) if family else QFont(self.font())
|
||||
font.setPointSize(max(1, base_pt + delta))
|
||||
label.setFont(font)
|
||||
label.setStyleSheet("color: black;")
|
||||
|
||||
# ---- player events ----
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
self._current_track = track
|
||||
if track is None:
|
||||
self._title_label.setText("")
|
||||
self._artist_label.setText("")
|
||||
self._slider.setRange(0, 0)
|
||||
self._elapsed_label.setText("")
|
||||
self._remaining_label.setText("")
|
||||
return
|
||||
self._title_label.setText(track.name)
|
||||
parts = [p for p in (track.artist, track.album) if p]
|
||||
self._artist_label.setText(" — ".join(parts))
|
||||
|
||||
def _on_playing_changed(self, playing):
|
||||
self._play_btn.set_kind("pause" if playing else "play")
|
||||
|
||||
def _on_position_changed(self, position):
|
||||
if not self._scrubbing:
|
||||
self._slider.setValue(position)
|
||||
self._update_time_labels(position)
|
||||
|
||||
def _on_duration_changed(self, duration):
|
||||
self._slider.setRange(0, max(0, duration))
|
||||
|
||||
def _update_time_labels(self, position):
|
||||
duration = self._slider.maximum()
|
||||
self._elapsed_label.setText(format_time(position) or "0:00")
|
||||
if duration > 0:
|
||||
self._remaining_label.setText("-" + (format_time(duration - position) or "0:00"))
|
||||
|
||||
# ---- scrubbing ----
|
||||
|
||||
def _on_scrub_start(self):
|
||||
self._scrubbing = True
|
||||
|
||||
def _on_scrub_move(self, value):
|
||||
self._update_time_labels(value)
|
||||
|
||||
def _on_scrub_end(self):
|
||||
self._scrubbing = False
|
||||
self._player.seek(self._slider.value())
|
||||
@@ -0,0 +1,187 @@
|
||||
"""20-band graphic-EQ visualizer fed by the player's decoded PCM tee.
|
||||
|
||||
The player's QAudioBufferOutput delivers buffers essentially in lockstep
|
||||
with the audible position (verified: frames received ≈ frames played), so
|
||||
no extra sync buffering is needed — we just FFT the newest samples.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF
|
||||
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QPen
|
||||
from PyQt6.QtMultimedia import QAudioFormat
|
||||
|
||||
|
||||
BANDS = 20
|
||||
STEPS = 20 # vertical granularity per bar
|
||||
FFT_SIZE = 2048
|
||||
FRAME_MS = 16 # ~60 fps
|
||||
DECAY = 0.82 # slow decay; attack is instant
|
||||
FLOOR_DB = -55.0
|
||||
RADIUS = 8 # rounded corners, matching the transport button boxes
|
||||
|
||||
# Visualizer click cycles through these brightness modes in order.
|
||||
MODE_ON = "on"
|
||||
MODE_DIM = "dim"
|
||||
MODE_OFF = "off"
|
||||
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
|
||||
|
||||
_SAMPLE_DTYPES = {
|
||||
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
|
||||
QAudioFormat.SampleFormat.Int16: (np.int16, 0.0, 32768.0),
|
||||
QAudioFormat.SampleFormat.Int32: (np.int32, 0.0, 2147483648.0),
|
||||
QAudioFormat.SampleFormat.Float: (np.float32, 0.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
class VisualizerWidget(QWidget):
|
||||
"""Animated spectrum bars; click to cycle brightness: on / dim / off.
|
||||
|
||||
On = highlight-colored bars; dim = light-gray bars just darker than the
|
||||
panel; off = blank.
|
||||
"""
|
||||
|
||||
def __init__(self, player, parent=None):
|
||||
super().__init__(parent)
|
||||
self._player = player
|
||||
self._mode = MODE_ON
|
||||
self._levels = np.zeros(BANDS)
|
||||
self._bars = [0] * BANDS # quantized 0..STEPS
|
||||
self._samples = np.zeros(FFT_SIZE, dtype=np.float32)
|
||||
self._sample_rate = 44100
|
||||
self._window = np.hanning(FFT_SIZE).astype(np.float32)
|
||||
|
||||
self.setFixedWidth(140)
|
||||
self.setMinimumHeight(36)
|
||||
self.setToolTip("Click to cycle: on / dim / off")
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(FRAME_MS)
|
||||
self._timer.timeout.connect(self._advance_frame)
|
||||
|
||||
player.audio_buffer.connect(self._on_buffer)
|
||||
player.playing_changed.connect(self._on_playing_changed)
|
||||
player.track_changed.connect(self._on_track_changed)
|
||||
|
||||
# ---- audio intake ----
|
||||
|
||||
def _on_buffer(self, buf):
|
||||
fmt = buf.format()
|
||||
spec = _SAMPLE_DTYPES.get(fmt.sampleFormat())
|
||||
if spec is None:
|
||||
return
|
||||
dtype, offset, scale = spec
|
||||
pointer = buf.data()
|
||||
pointer.setsize(buf.byteCount())
|
||||
raw = np.frombuffer(pointer, dtype=dtype)
|
||||
channels = max(1, fmt.channelCount())
|
||||
if channels > 1:
|
||||
usable = (len(raw) // channels) * channels
|
||||
raw = raw[:usable].reshape(-1, channels).mean(axis=1)
|
||||
mono = (raw.astype(np.float32) - offset) / scale
|
||||
self._sample_rate = fmt.sampleRate() or 44100
|
||||
|
||||
if len(mono) >= FFT_SIZE:
|
||||
self._samples = mono[-FFT_SIZE:].copy()
|
||||
else:
|
||||
self._samples = np.roll(self._samples, -len(mono))
|
||||
self._samples[-len(mono):] = mono
|
||||
|
||||
# ---- state ----
|
||||
|
||||
def _on_playing_changed(self, playing):
|
||||
if playing:
|
||||
if self._mode != MODE_OFF:
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
if self._mode != MODE_OFF:
|
||||
self._clear()
|
||||
|
||||
def _on_track_changed(self, track):
|
||||
self._samples[:] = 0.0
|
||||
if track is None and self._mode != MODE_OFF:
|
||||
self._timer.stop()
|
||||
self._clear()
|
||||
|
||||
def _clear(self):
|
||||
self._levels[:] = 0.0
|
||||
self._bars = [0] * BANDS
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
# Cycle on -> dim -> off -> on.
|
||||
idx = _MODE_CYCLE.index(self._mode)
|
||||
self._mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
||||
if self._mode == MODE_OFF:
|
||||
self._timer.stop()
|
||||
self._clear() # off = blank, not the last frame held in place
|
||||
elif self._player.is_playing():
|
||||
self._timer.start()
|
||||
|
||||
# ---- frame computation ----
|
||||
|
||||
def _advance_frame(self):
|
||||
spectrum = np.abs(np.fft.rfft(self._samples * self._window))
|
||||
# Normalize: full-scale sine -> ~1.0
|
||||
spectrum /= (FFT_SIZE / 4)
|
||||
|
||||
nyquist = self._sample_rate / 2
|
||||
top = min(16000.0, nyquist * 0.95)
|
||||
edges = np.geomspace(40.0, max(top, 80.0), BANDS + 1)
|
||||
freqs = np.fft.rfftfreq(FFT_SIZE, d=1.0 / self._sample_rate)
|
||||
|
||||
new_levels = np.zeros(BANDS)
|
||||
for i in range(BANDS):
|
||||
mask = (freqs >= edges[i]) & (freqs < edges[i + 1])
|
||||
if mask.any():
|
||||
magnitude = spectrum[mask].max()
|
||||
db = 20.0 * np.log10(magnitude + 1e-10)
|
||||
new_levels[i] = np.clip(1.0 - db / FLOOR_DB, 0.0, 1.0)
|
||||
|
||||
# Instant attack, slow decay
|
||||
self._levels = np.maximum(new_levels, self._levels * DECAY)
|
||||
bars = [int(round(level * STEPS)) for level in self._levels]
|
||||
if bars != self._bars:
|
||||
self._bars = bars
|
||||
self.update()
|
||||
|
||||
# ---- painting ----
|
||||
|
||||
def _bar_color(self):
|
||||
if self._mode == MODE_DIM:
|
||||
# Light gray just darker than the panel: take alternateBase and
|
||||
# drop its lightness ~12% so it tracks theme/scale changes.
|
||||
base = QColor(self.palette().alternateBase().color())
|
||||
h, s, l, a = base.getHslF()
|
||||
base.setHslF(h, s, l * 0.88, a)
|
||||
return base
|
||||
return QColor(self.palette().highlight().color())
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# Rounded gray panel + 1px border, matching the transport button boxes.
|
||||
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
panel = QPainterPath()
|
||||
panel.addRoundedRect(rect, RADIUS, RADIUS)
|
||||
painter.fillPath(panel, self.palette().alternateBase().color())
|
||||
painter.setPen(QPen(self.palette().mid().color(), 1))
|
||||
painter.drawPath(panel)
|
||||
painter.setClipPath(panel) # keep bars inside the rounded shape
|
||||
inset = 3 # bar field sits 3px in from every edge
|
||||
avail_w = self.width() - 2 * inset
|
||||
avail_h = self.height() - 2 * inset
|
||||
baseline = self.height() - inset
|
||||
bar_width = avail_w / BANDS
|
||||
color = self._bar_color()
|
||||
step_height = avail_h / STEPS
|
||||
for i, bar in enumerate(self._bars):
|
||||
if bar <= 0:
|
||||
continue
|
||||
x = inset + int(i * bar_width)
|
||||
end = inset + int((i + 1) * bar_width)
|
||||
gap = 1 if i < BANDS - 1 else 0 # 1px between bars, none at edge
|
||||
w = max(1, end - x - gap)
|
||||
bar_height = int(bar * step_height)
|
||||
painter.fillRect(x, baseline - bar_height, w, bar_height, color)
|
||||
Reference in New Issue
Block a user