The first half of andTunes, a music player for the Rabbit R1. Auxio re-reads Android's MediaStore on every launch, which is the "your songs will show up here" hang — so the answer isn't a faster scanner, it's not scanning at all. LinTunes already knows the artist, album and year of every file it just copied, so it writes them into Music/andTunes/library.json and the app reads one file instead of indexing a filesystem. New lintunes/andtunes/: layout.py (a de-duplicated Media/<Artist>/<Album>/ tree, so a song in three playlists is stored once), manifest.py (pure, Qt-free), art.py (one 480 px cover per album via QImage, cached and invalidated by the audio file's mtime — which is exactly what embedding new art moves), and sync.py, a third sibling of plan_export/ExportWorker. Three rules the code depends on: planning never renders art (a mutagen open per album, and planning runs on the GUI thread); the index is written last and after a cancel lists only tracks whose files actually landed; and every delete goes through layout.assert_inside, which is why the old Music/<Playlist>/ folders are structurally unreachable rather than merely un-referenced. Connections gains "Sync to Rabbit" and "Rabbit Sync Settings…" — the ticked set is the device's contents, so unticking is how a playlist comes off, which nothing could do before. The selection rides preferences.json so both machines agree. The old per-playlist sync stays, renamed "(Auxio)": removing it now would leave the device full of files and nothing able to open them until the app exists. The app itself is rounds 48-50; its board is andtunes/TASKS.md, including the measured screen facts (480x640 px at density override 160 — one dp is half its usual physical size on a 2.88" panel). Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
"""Pick which playlists live on the Rabbit.
|
|
|
|
The old menu synced whichever playlist happened to be open, which made
|
|
"remove this from the device" an operation with no button: nothing ever
|
|
deleted a playlist folder. Here the ticked set *is* the device's contents —
|
|
unticking is how a playlist comes off, and the next sync does it.
|
|
|
|
The selection is stored in preferences.json, so it rides the same Syncthing
|
|
share as the library and both machines agree on what belongs on the device.
|
|
"""
|
|
|
|
from PyQt6.QtCore import Qt
|
|
from PyQt6.QtWidgets import (
|
|
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QListWidget,
|
|
QListWidgetItem, QVBoxLayout,
|
|
)
|
|
|
|
from ..models.playlist import PlaylistType
|
|
|
|
|
|
def syncable_playlists(library) -> list:
|
|
"""Playlists a user can put on a device, in name order.
|
|
|
|
Folders hold no tracks and system playlists aren't the user's to sync;
|
|
a smart playlist is fine — by the time we see it, its membership has
|
|
already been computed.
|
|
"""
|
|
return sorted(
|
|
(p for p in library.playlists.values()
|
|
if p.playlist_type != PlaylistType.FOLDER and not p.is_system),
|
|
key=lambda p: p.name.casefold())
|
|
|
|
|
|
class DeviceSyncSettingsDialog(QDialog):
|
|
def __init__(self, prefs, library, parent=None, device_name="Rabbit"):
|
|
super().__init__(parent)
|
|
self.setWindowTitle(f"{device_name} Sync Settings")
|
|
self._prefs = prefs
|
|
self._playlists = syncable_playlists(library)
|
|
|
|
selected = set(prefs.device_sync.get("playlist_ids", []))
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.addWidget(QLabel(
|
|
f"Playlists to keep on the {device_name}. Unticking one removes "
|
|
"it from the device on the next sync."))
|
|
|
|
# 500 playlists is a normal library here, so the list is unusable
|
|
# without a filter. Hiding rows doesn't renumber them, so the
|
|
# row-to-playlist mapping in selected_ids() stays valid.
|
|
self._filter = QLineEdit(self)
|
|
self._filter.setPlaceholderText("Filter playlists…")
|
|
self._filter.setClearButtonEnabled(True)
|
|
self._filter.textChanged.connect(self._apply_filter)
|
|
layout.addWidget(self._filter)
|
|
|
|
self._list = QListWidget(self)
|
|
for playlist in self._playlists:
|
|
item = QListWidgetItem(
|
|
f"{playlist.name} ({len(playlist.track_ids)})", self._list)
|
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
item.setCheckState(
|
|
Qt.CheckState.Checked
|
|
if playlist.persistent_id in selected
|
|
else Qt.CheckState.Unchecked)
|
|
layout.addWidget(self._list)
|
|
|
|
self._count = QLabel(self)
|
|
layout.addWidget(self._count)
|
|
self._list.itemChanged.connect(lambda _item: self._refresh_count())
|
|
self._refresh_count()
|
|
|
|
self._art = QCheckBox("Copy album art to the device", self)
|
|
self._art.setChecked(bool(prefs.device_sync.get("copy_art", True)))
|
|
self._art.setToolTip(
|
|
"Covers are read from the tags and scaled to 480 px — one file "
|
|
"per album, not per song.")
|
|
layout.addWidget(self._art)
|
|
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok
|
|
| QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
buttons.accepted.connect(self.accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
def _apply_filter(self, text: str):
|
|
needle = text.strip().casefold()
|
|
for row, playlist in enumerate(self._playlists):
|
|
self._list.item(row).setHidden(
|
|
bool(needle) and needle not in playlist.name.casefold())
|
|
|
|
def _refresh_count(self):
|
|
n = len(self.selected_ids())
|
|
self._count.setText(
|
|
f"{n} playlist{'' if n == 1 else 's'} selected"
|
|
if n else "No playlists selected — nothing will be synced.")
|
|
|
|
def selected_ids(self) -> list:
|
|
return [playlist.persistent_id
|
|
for row, playlist in enumerate(self._playlists)
|
|
if self._list.item(row).checkState() == Qt.CheckState.Checked]
|
|
|
|
def accept(self):
|
|
self._prefs.update_device_sync(playlist_ids=self.selected_ids(),
|
|
copy_art=self._art.isChecked())
|
|
super().accept()
|