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:
2026-06-26 21:12:01 -04:00
commit b7f8761bd1
70 changed files with 8992 additions and 0 deletions
View File
+36
View File
@@ -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)
+137
View File
@@ -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
+78
View File
@@ -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,
}
+456
View File
@@ -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()
+305
View File
@@ -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()
+405
View File
@@ -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)
+67
View File
@@ -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
+143
View File
@@ -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", []))
+154
View File
@@ -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', '')}")
+409
View File
@@ -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)
+747
View File
@@ -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)
+360
View File
@@ -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())
+187
View File
@@ -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)