v0.25.0: New Tracks, a soft emboss, date added pinned

Groundwork for Cassette (friend library sharing) that stands on its own:

- A brand-new library (fresh data dir, or a fresh iTunes import) starts
  with a "New Tracks" smart playlist: added in the last 3 months, newest
  first. Fixed persistent id, so two machines seeding one unsynced share
  merge into a single playlist.
- Every non-flat button, the sidebar Library button and the transport
  boxes get a very slight top-lit gradient computed from the live palette,
  with the chosen color kept exactly at its midpoint.
- Tests pin that individual imports stamp date added = now even for an
  old file, and that the iTunes migration keeps the XML's dates.
This commit is contained in:
2026-09-28 20:56:28 -07:00
parent 6fc6c335c5
commit 549c5bf94f
9 changed files with 275 additions and 11 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.24.1"
__version__ = "0.25.0"
+1
View File
@@ -286,6 +286,7 @@ class MainWindow(QMainWindow):
self.apply_ui_metrics()
self._transport.refresh_theme()
self._version_button.refresh_theme()
self._sidebar.refresh_style()
def apply_ui_metrics(self):
metrics = theme.scale_metrics(self._prefs)
+19 -9
View File
@@ -1,12 +1,12 @@
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
)
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPixmap
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
from lintunes.models import PlaylistType
from lintunes import tagging
from lintunes import tagging, theme
from lintunes.gui.track_table import (
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
)
@@ -39,12 +39,7 @@ class SidebarPanel(QWidget):
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); }")
self.refresh_style()
layout.addWidget(self.library_button)
self.tree = PlaylistTree(manager)
@@ -85,6 +80,21 @@ class SidebarPanel(QWidget):
def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip)
def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its
emboss follows theme changes like every other button's."""
palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;"
" margin: 2px 4px 2px 8px;"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};"
" color: palette(highlighted-text); }")
class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the
+1 -1
View File
@@ -36,7 +36,7 @@ def _box_style(bg: QColor) -> str:
QFrame#transportBox {{
border: 1px solid palette(mid);
border-radius: 8px;
background: {bg.name()};
background: {theme.emboss_gradient(bg)};
}}
"""
+10
View File
@@ -145,6 +145,16 @@ class LibraryManager(QObject):
redo=lambda: self._insert_playlists([folder])))
return folder
def seed_new_library(self):
"""Add the playlists a brand-new library starts with (not undoable —
there is nothing before them to go back to)."""
from lintunes.seed_playlists import seed_new_library
added = seed_new_library(dict(self.library.playlists))
if added:
self._insert_playlists(added)
for playlist in added:
self.recompute_smart_playlist(playlist.persistent_id, force=True)
def create_smart_playlist(self, name: str, criteria, parent_pid: str = "") -> Playlist:
playlist = Playlist(
name=name,
+8
View File
@@ -93,6 +93,9 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
library.music_folder_rel = to_relative(library.music_folder, data_dir)
library.music_folder_set_at = (
datetime.now(timezone.utc).replace(tzinfo=None).isoformat())
# A fresh import is a new library, so it starts with the seed playlists.
from lintunes.seed_playlists import seed_new_library
seed_new_library(library.playlists)
if report.missing_files:
missing_log = data_dir / "import_missing_files.txt"
@@ -134,6 +137,9 @@ def run_gui(data_dir: Path, files: list[Path],
from lintunes.sync_watcher import LibrarySyncWatcher
startup_conflicts = resolve_conflicts(data_dir)
# No library.json means a library is being born here (not merely an empty
# one), which is the one moment the seed playlists are added.
new_library = not (data_dir / "library.json").exists()
library = load_library(data_dir)
app = QApplication([sys.argv[0]] + (qt_args or []))
@@ -147,6 +153,8 @@ def run_gui(data_dir: Path, files: list[Path],
prefs = Preferences(data_dir)
theme.apply_theme(app, prefs)
manager = LibraryManager(library, data_dir)
if new_library:
manager.seed_new_library()
lastfm = LastFm(prefs, data_dir)
window = MainWindow(manager, prefs, lastfm)
app.installEventFilter(window)
+45
View File
@@ -0,0 +1,45 @@
"""Playlists a brand-new library starts with.
Seeded once, when a library is born: a fresh empty data dir (``run_gui``) or a
fresh iTunes import. An existing library never gains one, and a seeded playlist
is an ordinary smart playlist afterwards — rename it, edit it, delete it.
The persistent id is **fixed**, not random: two machines that each start a new
library in the same (not yet synced) share both seed it, and the conflict merge
then sees one playlist with two copies instead of two playlists both called
"New Tracks".
"""
from lintunes.models import Playlist, PlaylistType
from lintunes.models.playlist import PlaylistSettings
from lintunes.smart import SmartCriteria, SmartGroup, SmartRule
NEW_TRACKS_NAME = "New Tracks"
NEW_TRACKS_PID = "4E4557545241434B" # "NEWTRACK" in hex
def new_tracks_playlist() -> Playlist:
"""Smart playlist of everything added in the last three months, newest
first."""
criteria = SmartCriteria(root=SmartGroup(conjunction="all", children=[
SmartRule(field="date_added", operator="in_last", value=3,
unit="months"),
]))
return Playlist(
name=NEW_TRACKS_NAME,
persistent_id=NEW_TRACKS_PID,
playlist_type=PlaylistType.SMART,
smart_criteria=criteria,
settings=PlaylistSettings(sort_column="date_added",
sort_ascending=False),
)
def seed_new_library(playlists: dict) -> list[Playlist]:
"""Add the seed playlists to ``playlists`` (persistent id → Playlist)
unless a playlist with that id is already there. Returns what was added."""
added = []
for playlist in (new_tracks_playlist(),):
if playlist.persistent_id not in playlists:
playlists[playlist.persistent_id] = playlist
added.append(playlist)
return added
+43
View File
@@ -156,13 +156,56 @@ def apply_theme(app, prefs):
app.setFont(font)
bar = metrics["scrollbar"]
button = palette.color(QPalette.ColorRole.Button)
app.setStyleSheet(f"""
QScrollBar:vertical {{ width: {bar}px; }}
QScrollBar:horizontal {{ height: {bar}px; }}
QScrollBar::handle {{ min-height: {bar * 2}px; min-width: {bar * 2}px; }}
{button_emboss_qss(button)}
""")
def emboss_gradient(color: QColor, pressed: bool = False) -> str:
"""A barely-there top-lit vertical gradient around ``color``.
Computed from the live color rather than hard-coded, so it follows the
palette (light or dark) and the user's grayscale overrides. ``pressed``
flips it, which reads as the button sinking in."""
top, bottom = color.lighter(EMBOSS_LIGHTER), color.darker(EMBOSS_DARKER)
if pressed:
top, bottom = bottom, top
# The color itself sits at the midpoint, so what the user picked is still
# exactly what the middle of the button shows.
return (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {top.name()}, stop:0.5 {color.name()}, "
f"stop:1 {bottom.name()})")
# How far the emboss strays from the base color (Qt's lighter/darker factor,
# 100 = unchanged). Kept small on purpose: "very slight, soft".
EMBOSS_LIGHTER = 106
EMBOSS_DARKER = 104
def button_emboss_qss(button: QColor) -> str:
"""Global rule for every non-flat QPushButton. Flat buttons (the version
button, the cancel-transfer ✕, the bpm label) opt out by being flat."""
hover = button.lighter(103)
return f"""
QPushButton:!flat {{
border: 1px solid palette(mid); border-radius: 4px;
padding: 3px 12px; min-height: 1.3em;
background: {emboss_gradient(button)};
}}
QPushButton:!flat:hover {{ background: {emboss_gradient(hover)}; }}
QPushButton:!flat:pressed, QPushButton:!flat:checked {{
background: {emboss_gradient(button, pressed=True)};
}}
QPushButton:!flat:default {{ border-color: palette(highlight); }}
QPushButton:!flat:disabled {{ color: palette(mid); }}
"""
def installed_families() -> set[str]:
"""Families Qt can actually resolve on this machine."""
return set(QFontDatabase.families())