A paused or stopped QMediaPlayer on Qt's FFmpeg backend keeps its PipeWire stream open and never corks or drains it, so the few seconds the backend decoded ahead sit there live. A later audio-graph change — a USB DAC waking from idle suspend, a device appearing — flushes that stale buffer to the speakers, hours after the app was last touched. That is the "LinTunes plays by itself" haunting: the v0.1.4 provenance log recorded zero control events between the 17:48 pause and the 00:30 incident, while MPRIS still reported Paused at exactly 6974000us — the position it was paused at seven hours earlier — and PipeWire showed the stream state=running. It explains the whole shape of it: always a short burst (only what was buffered), always mid-song, never a recorded play count. After 30s idle, release the pipeline: stop() then setSource(QUrl()), which removes the PipeWire node outright, so no buffer survives to leak. Gain drops to zero first as an independent second layer. stop() arms the brake too — Qt leaves the source loaded there, and _advance() takes that path when a playlist runs out. Resume rebuilds the source and seeks back via the existing custom-start-time machinery. Verified end-to-end against the real LocalSink with a silent WAV, watched through pw-dump: present while playing, still present right after pause, gone once parked, rebuilt on resume with position and duration intact. This is a workaround for an upstream Qt Multimedia bug; TASKS.md now carries the assessment for moving the local sink to GStreamer, which gets correct pause behavior for free and would unlock the parked equalizer and gapless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
26 KiB
Markdown
400 lines
26 KiB
Markdown
## Done
|
||
|
||
### Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1)
|
||
|
||
The long-running "LinTunes plays by itself" haunting, finally attributed and
|
||
fixed. Five-plus incidents since July, always the same shape: a few seconds of
|
||
audio from the middle of a song, at some random hour, hours after the app was
|
||
last touched, never recording a play count.
|
||
|
||
It was never a control event. The v0.1.4 provenance log had **zero** entries
|
||
between the 17:48 pause and the 00:30 incident — no MPRIS, no media key, no
|
||
AVRCP ghost — while MPRIS still reported `Paused` at exactly `6974000µs`, the
|
||
same position it was paused at seven hours earlier. Meanwhile PipeWire showed
|
||
LinTunes owning a stream in state `running`, linked to the DAC, gain 1.0.
|
||
|
||
Cause: a paused (or stopped) `QMediaPlayer` on the FFmpeg backend keeps its
|
||
PipeWire stream open and never corks or drains it, so the few seconds the
|
||
backend had decoded ahead sit there live. When the audio graph is later rewired
|
||
— a USB DAC waking from idle suspend, a device appearing — that stale buffer
|
||
flushes to the speakers. It explains every observation: always a short burst
|
||
(it can only be what was buffered), always mid-song, never a play count (nothing
|
||
resumed), and why the Fedora machine never does it (no USB DAC to wake up).
|
||
Reproduced standalone on Qt 6.8.2 / FFmpeg 7.1.5 in ~20 lines.
|
||
|
||
- [x] `PARK_AFTER_IDLE_S` (30s) + `LocalSink._park()` / `_unpark()`. After that
|
||
long idle, release the pipeline: `stop()` then `setSource(QUrl())`, which
|
||
is verified to remove the PipeWire node outright — there is no buffer left
|
||
that could ever leak. Resume rebuilds the source and rides the existing
|
||
custom-start-time machinery to seek back.
|
||
- [x] Two independent layers, deliberately: gain goes to 0 *before* the
|
||
teardown, so anything still buffered downstream drains silently even if
|
||
the release misbehaves. `_apply_volume()` forces silence while parked, so
|
||
the volume slider can't re-arm it.
|
||
- [x] `stop()` arms the brake too — Qt's `stop()` leaves the source loaded, and
|
||
`Player._advance()` calls it when a playlist runs out, so "playlist ended
|
||
at 1am" was a second, equally real leak path.
|
||
- [x] Parked state serves `position_ms()`/`duration_ms()` from a cache and
|
||
suppresses the media object's position/duration/state/status signals, so
|
||
the teardown never reaches the UI as a jump to 0:00 and a stray
|
||
`EndOfMedia` can't advance the queue.
|
||
- [x] `seek()` while parked moves the resume point instead of poking a dead
|
||
pipeline; `load()` discards parked state; `shutdown()` disarms the timer.
|
||
- [x] Verified end-to-end against the real `LocalSink` (silent WAV, zero gain,
|
||
watched via `pw-dump`): stream present while playing, still present right
|
||
after pause, **gone** once parked, rebuilt on resume with position and
|
||
duration intact. Tests in `tests/test_round34.py` (17).
|
||
|
||
Note: this is a workaround for an upstream Qt Multimedia bug, not a LinTunes
|
||
design flaw — GStreamer-based players get correct pause behavior for free by
|
||
corking. See the backend note under "Parked / deferred".
|
||
|
||
### Round 33 (2026-08-14) — Delete songs from the library (v0.6.0)
|
||
|
||
An iTunes 12 habit LinTunes had no answer for: getting rid of a song you don't
|
||
want to keep. `LibraryManager` had `add_track` but no inverse, and nothing in the
|
||
app had ever deleted a local music file. Deletion goes to the desktop trash
|
||
rather than `unlink`, so it's recoverable both by Ctrl+Z and from the file
|
||
manager afterwards.
|
||
|
||
Two constraints shaped it. The trash is **per-filesystem** — the music sits on a
|
||
mounted volume, so the right destination is `<topdir>/.Trash-<uid>`, and using
|
||
the home trash would mean a cross-device copy plus a recorded original path
|
||
"Restore" can't reach. And playlist cleanup has to be **synchronous with the
|
||
removal**: playlist edits address tracks by row index into `track_ids` while the
|
||
view skips ids missing from the library, so a dangling id would desync the two
|
||
and make a later "Remove from Playlist" hit the wrong track.
|
||
|
||
- [x] `lintunes/trash.py` — freedesktop Trash spec 1.0 by hand, no new
|
||
dependency (a new pip dep would need a manual `pip install -e .` on the
|
||
other machine). Picks the trash by the file's own mount, writes the
|
||
percent-encoded `.trashinfo` with `O_EXCL` first to claim the name, then
|
||
renames the file in; a failed rename cleans up the info file. `TrashError`
|
||
leaves the file untouched.
|
||
- [x] `LibraryManager.delete_tracks(ids, delete_files=)` — the single funnel.
|
||
Removes from `library.tracks` and every non-smart playlist, prunes the
|
||
dirs it emptied, invalidates the exported artwork, and pushes one undo
|
||
Command covering the whole selection. A file that can't be trashed
|
||
**keeps its track** rather than leaving an orphaned entry. New
|
||
`tracks_removed` / `tracks_restored` signals.
|
||
- [x] Undo restores the file from the trash *and* the track's original position
|
||
in each playlist; redo re-trashes (re-recording paths, since a redo can
|
||
land on a different collision-suffixed name). If the trash was emptied
|
||
behind us, the library entry is restored anyway — a visible broken track
|
||
beats a silent second loss.
|
||
- [x] `Player.drop_tracks()` — stops if the deleted track is playing, otherwise
|
||
keeps the current track's place in the shortened queue. Previously a dead
|
||
id in the queue made `_load_current` stop everything with an error dialog
|
||
once the walk reached it.
|
||
- [x] Context menu gains "Remove from Library" and "Remove from Library and
|
||
Delete File", in **both** the library and playlist views. No keyboard
|
||
shortcut — `Del` still means "remove from playlist". Confirmation uses the
|
||
existing destructive-dialog idiom (DestructiveRole button, Cancel as
|
||
default) and names the playlists affected.
|
||
- [x] `tests/test_round33.py` (26 tests) covers the spec details (volume vs home
|
||
trash, percent-encoding, collision suffixes, no orphaned info file on
|
||
failure), the manager (playlist cleanup, pruning, partial failure, undo of
|
||
position, emptied-trash undo) and `drop_tracks`. Verified for real against
|
||
`/run/media/trav/tummult/.Trash-1000` with a scratch file.
|
||
|
||
### Round 32 (2026-08-14) — Custom start times survive a track change (v0.5.2)
|
||
|
||
trav reported "Pretty Girls is a Motherfucker" starting at 0:00 despite its
|
||
imported 0:40 start time. Import and storage were fine (70 tracks carry a start
|
||
time, 72 a stop time); the seek was being thrown away. `QMediaPlayer.setSource()`
|
||
synchronously emits *two* status changes before it returns — a `LoadedMedia`
|
||
still reporting the **outgoing** source, then `LoadingMedia` for the new one —
|
||
and the stale first event consumed the one-shot `_pending_start_ms` armed by
|
||
Round 22, leaving nothing for the new track's real `LoadedMedia` ~5 ms later.
|
||
So it misfired on every track change; only the first track after launch worked,
|
||
which is why Round 22 looked green.
|
||
|
||
- [x] The armed seek is tagged with the URL it belongs to
|
||
(`_pending_start_url`), and `_on_media_status` consumes it only when
|
||
`source()` matches — a status change fired for other media can't eat it.
|
||
- [x] `LocalSink.load` restructured so the pending seek is armed only on the
|
||
new-source branch; Round 22's same-source replay path still seeks directly
|
||
and arms nothing.
|
||
- [x] `tests/test_round32.py` models the real Qt event sequence (a `setSource`
|
||
side effect that fires the stale `LoadedMedia` before switching `source()`),
|
||
which Round 22's plain `MagicMock` never did. All three behavioral tests fail
|
||
on the old code. `test_round8` / `test_round22` stubs updated to report the
|
||
new source by the time its `LoadedMedia` arrives, as real Qt does.
|
||
- [x] Verified against the real files headless: switching from a playing track
|
||
to the Psychic Vagina mp3 with `start_ms=40000` now lands at 40.1 s and
|
||
climbs; a track with no start time still starts at 0.
|
||
|
||
### Round 31 (2026-08-13) — One cast control, not two (v0.5.1)
|
||
|
||
The cast glyph appeared both under the volume slider and inside the visualizer,
|
||
which was redundant. Dropped the volume-slider indicator (`gui/cast_indicator.py`
|
||
deleted); the visualizer panel is now the single cast control.
|
||
|
||
- [x] **The visualizer panel is the indicator** — bigger glyph (20 → 40 px),
|
||
always painted in the theme highlight rather than following the brightness
|
||
mode, and clicking it stops casting instead of cycling on/dim/off (which
|
||
meant nothing with no bars to dim). New `cast_stop_requested` signal.
|
||
- [x] **`transport_icon(kind, color, size=…)`** scales the painter instead of
|
||
upscaling a 20px pixmap, so the panel-sized glyph is crisp; `size` joins the
|
||
cache key.
|
||
- [x] Collapsed the `cast`/`cast_connected` glyph pair into one — only the
|
||
connected state was ever drawn.
|
||
|
||
### Round 30 (2026-08-13) — Album art on the cast device (v0.5.0)
|
||
|
||
The Chromecast is on a TV, so it should show the cover. `play_media` now
|
||
carries `thumb=`, which pychromecast folds into `metadata["images"]` — the
|
||
field the receiver paints full-screen. Verified against the real device: it
|
||
fetches both the audio and the artwork URL from us on every track change.
|
||
|
||
- [x] **`TrackServer` serves in-memory blobs** — album art lives in the audio
|
||
file's tags, not as a file of its own, so a token now resolves to an
|
||
`_Asset` that is either a path or bytes. Audio and art get **separate
|
||
eviction rings**, so a cover can't push out the previous track's audio while
|
||
a device is still fetching it. Range and HEAD work on both.
|
||
- [x] **`support.image_type_for`** sniffs the cover's type from its magic bytes
|
||
rather than trusting the tag — ID3 APIC mimes are routinely wrong or blank,
|
||
and the receiver silently drops an image whose type doesn't match.
|
||
- [x] **Best-effort throughout** — no cover, junk where the cover should be, or
|
||
an unreadable file all just play without art rather than failing the load.
|
||
- [x] Also sends `albumArtist` and `trackNumber` in the metadata.
|
||
|
||
### Round 29 (2026-08-13) — Cast to Chromecast (v0.4.0)
|
||
|
||
The Device menu is now **Connections**, with "Connect to Chromecast…" alongside
|
||
the Rabbit sync. Picking a device from the search dialog hands playback to it;
|
||
a small cast glyph appears under the volume slider and clicking it disconnects.
|
||
See `tests/test_round29.py` (67 tests).
|
||
|
||
Uses the **media-receiver model**: lintunes serves the original file over the
|
||
LAN and the Chromecast decodes it. Bit-exact (no transcode, no double-lossy on
|
||
already-lossy files) and the device buffers for itself; the cost is that
|
||
lintunes becomes a remote control while connected — no local PCM, so the
|
||
visualizer shows the cast glyph instead of bars, and transport actions land
|
||
with ~1 s of round trip.
|
||
|
||
- [x] **`lintunes/cast/`** — `support.py` (format gate, source-address
|
||
selection, range parsing), `server.py` (token-addressed
|
||
`ThreadingHTTPServer` with Range/HEAD), `discovery.py` (`CastBrowser` behind
|
||
Qt signals), `sink.py` (`CastSink`), `controller.py` (session lifecycle +
|
||
its own `SleepInhibitor`).
|
||
- [x] **`Player` sink split** — new `PlaybackSink` base with `LocalSink` and
|
||
`CastSink`; `set_sink()` carries track/position/playing-state both ways, so
|
||
connecting and disconnecting pick up mid-song. Play counts and Last.fm
|
||
scrobbles fire identically on both paths.
|
||
- [x] **Format handling** — 12,610 MP3 + ~8,750 AAC cast natively; the ~119
|
||
Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar
|
||
message rather than stalling the device.
|
||
- [x] **Failure handling** — a dropped socket gets a 15 s grace period
|
||
(pychromecast retries on its own, so a Wi-Fi blip heals itself); a real loss,
|
||
a foreign app taking the device, or a network change falls back to local
|
||
playback **still playing**, at the same position. Quitting stops the device
|
||
rather than leaving it fetching from a dead server.
|
||
- [x] **New dependency: `pychromecast>=14.0.10`**, imported lazily so the app
|
||
still launches where it isn't installed; `python_requires` raised to >=3.11
|
||
(its floor). The menu item explains the `pip install -e .` when missing.
|
||
|
||
### Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite)
|
||
|
||
- [x] Confirm Last.fm scrobbling works
|
||
- [x] Volume slider between the visualizer and the timeline
|
||
- [x] Preferences: per-area color adjustments (background, button
|
||
round-rects, now-playing text + its backing, tracklist stripe gray), each
|
||
with a live black↔white slider
|
||
- [x] Visualizer: discrete light-gray-on-light-gray mode
|
||
- [x] Cut/copy/paste tracks (Ctrl+X/C/V); paste inserts above the selected
|
||
track, not at the end
|
||
- [x] Auto-scroll the track list when dragging a track above/below the
|
||
visible area
|
||
- [x] Move data dir to `/run/media/trav/tummult/music/lintunes/` +
|
||
relative track paths (see Round 15)
|
||
- [x] Real library import: 21,382 tracks, 462 playlists + 21 folders,
|
||
18 smart playlists (3 kept as snapshot), 5 missing files, 1,324
|
||
case/unicode path fixes (see Round 15)
|
||
|
||
### Round 16 (2026-07-02) — Live multi-machine sync + conflict alerts
|
||
|
||
Both machines now reflect each other's changes within a couple seconds, with
|
||
genuine conflicts auto-merged (backed up first) and surfaced in an alert. See
|
||
`tests/test_round16.py` (9 tests) + the known limitations below.
|
||
|
||
- [x] **Live external-change reload** — new `lintunes/sync_watcher.py`
|
||
(`QFileSystemWatcher`, 600 ms debounce, re-arms after atomic-rename replaces).
|
||
`LibraryManager.reload_from_disk()` re-reads and **reconciles** disk into memory
|
||
(max play/skip counts, newest edit wins, playlist membership unioned), keeping
|
||
local unsaved edits and object identity so open views stay valid; then refreshes
|
||
the UI without touching the player. **Self-write suppression:** `flush()` records
|
||
each file's `(mtime, size)` in `_own_sigs`; the watcher ignores changes matching
|
||
our own writes so a save never triggers a reload.
|
||
- [x] **Auto-merge + backups + summaries** — `storage/conflict_resolver.py` now
|
||
backs up *both* sides into `<data>/.resolved/<timestamp>/{original,incoming}/`
|
||
before merging, returns `list[ConflictSummary]`, and adds `restore_backup()`.
|
||
Conflicts are resolved at startup **and live** (via the watcher).
|
||
- [x] **Alert UI** — `gui/conflict_dialog.py` `ConflictSummaryDialog` (scrollable
|
||
summary, Open backup folder, Restore pre-merge backup), shown modeless from a
|
||
status-bar notice so a merge never interrupts playback. Wired in `main_window`
|
||
(`_on_library_reloaded` preserves scroll+selection; `_on_conflict_resolved`).
|
||
|
||
**Known limitations (v2):** a *simultaneous* conflicting playlist edit unions
|
||
membership, so a deletion made on one side while the other edits the same file can
|
||
be resurrected (clean, non-simultaneous deletes reload fine) — proper fix is
|
||
per-playlist tombstones. No per-change accept/refuse yet (whole-file restore only).
|
||
|
||
### Round 15 (2026-07-01) — Go live: portable multi-machine paths + final import
|
||
|
||
Prepared LinTunes to become the canonical library, synced to a second machine via
|
||
Syncthing. See `tests/test_round15.py` (10 tests).
|
||
|
||
- [x] **Portable track paths** (`lintunes/paths.py`) — track locations are stored
|
||
**relative to the data dir** (`to_relative`) and resolved back to absolute on load
|
||
(`to_absolute`), applied only at the `json_storage` boundary (`save_tracks` /
|
||
`load_library`). `Track.location` stays absolute in memory, so the player, tagging,
|
||
and art code are untouched. Because the data dir and the music live inside one
|
||
synced tree and move together, the library now resolves on any machine no matter
|
||
where Syncthing mounts the folder — no per-machine `music_root` config. Absolute
|
||
paths in older `library.json` files still load (back-compat).
|
||
- [x] **Final import + go live** — fresh import of the current `iTunes Library.xml`
|
||
into the synced data dir `/run/media/trav/tummult/music/lintunes/` (inside the
|
||
verified "music" Syncthing share), config saved via `--save-config`, preferences
|
||
(UI tuning + Last.fm) carried over from the old `./data`, which is retained as a
|
||
rollback backup. Git remote set to `git.autonomic.zone:2222/trav/lintunes` so the
|
||
second machine can clone.
|
||
|
||
### Round 14 (2026-06-26) — Smart playlists (Phase 1)
|
||
|
||
Auto-populating, criteria-driven playlists with full iTunes 12 import. See
|
||
`tests/test_round14.py` (27 tests, incl. real captured blobs in
|
||
`tests/smart_blobs.json`).
|
||
|
||
- [x] **Criteria model + evaluator** (`lintunes/smart.py`) — recursive
|
||
`SmartRule`/`SmartGroup`/`SmartLimit`/`SmartCriteria` (JSON round-tripping),
|
||
a shared `FIELD_REGISTRY` (field → Track attr, type, operators) used by both
|
||
evaluator and editor, and `evaluate()` (match all/any, nested groups,
|
||
string/int/duration/rating/date/bool ops, relative "in the last N", and
|
||
limit-by items/time/size with a "selected by" sort).
|
||
- [x] **iTunes import** — the binary "Smart Info"/"Smart Criteria" blobs are
|
||
decoded by a **vendored, MIT-licensed parser** (`lintunes/itunes_smart/`,
|
||
from github.com/cvzi/itunes_smartplaylist) and converted to our model by
|
||
`parse_itunes_smart()`. Nested rule groups **parse and evaluate** correctly;
|
||
blobs we can't represent (MediaKind/iCloud/etc.) flag `unsupported` and keep
|
||
the imported track snapshot. Validated against all 14 real smart playlists.
|
||
`loved` is deliberately dropped per user preference.
|
||
- [x] **Manager** (`library_manager.py`) — `create_smart_playlist` /
|
||
`set_smart_criteria` (undoable) / `recompute_smart_playlist` /
|
||
`recompute_all_smart`; field-scoped, coalesced live recompute hooked into the
|
||
track-edit/play/skip/add funnels (honours each playlist's `live_update`); a
|
||
no-op equality guard to avoid Syncthing churn; manual add/remove/reorder
|
||
blocked on smart playlists. `main.py` recomputes on load;
|
||
`conflict_resolver.py` takes newest criteria (no track_id union) for smart.
|
||
- [x] **GUI** — `❧ ` glyph on smart rows in the sidebar (with a rename-strip
|
||
delegate), distinct context menu (New/Edit Smart Playlist), read-only track
|
||
table for smart playlists, and `SmartPlaylistEditorDialog`
|
||
(`gui/smart_playlist_dialog.py`): per-field rule rows, match all/any, limits,
|
||
live-updating. New Smart Playlist on File menu (Ctrl+Alt+N).
|
||
|
||
**Phase 2 TODO:** nested-group **editing** UI in the dialog (imported nested
|
||
playlists are currently shown read-only with a banner; they still update/play).
|
||
|
||
### Round 8 (2026-06-19)
|
||
|
||
All four implemented in Round 8 (2026-06-19); see tests/test_round8.py.
|
||
|
||
- [x] the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist.
|
||
- [x] ability to edit the ID3 tags of multiple files at once and have it intelligently only modify only the id3 tags that the files share. Say we're editing all songs in an albumb: the song title isn't editable because it isn't the same between the tracks, but the album title would show and the artist. So we could make those changes to all songs at once. _(iTunes-style: differing fields show a "Mixed" placeholder but stay editable; only fields you actually touch are written to all selected tracks.)_
|
||
- [x] search, we need search. It only needs to appear in the library view. This should be a text box that takes up a third of that empty horizontal space between the tracks view and the top control panel. It should be a textbox that has in very light gray text that says "search". When you click into it that the "search" text disappears. As soon as you start typing it starts filtering the music. This should be compatible with the browser columns so the browser columns still show genres, artists and albums that match the search term. The search should search all fields for the search term(s). I could see this being really slow having to search all fields of all tracks at every keypress. So we can pause between key-presses if we want. So it's a search but really more like a filter. _(250 ms debounce.)_
|
||
- [x] I want support for custom start and end times of tracks like itunes has. We need to be able to import this data from how it's stored in the itunes library and we need to be able to respect it during playback. There should also be fields in the 'get info' screen that allows us to turn custom start/end times of the track on and off and set the value of how far into the song it starts/ends. This is a feature in itunes we're implementing. _(Import was already wired; added Get Info fields + playback seek-to-start/stop-at-stop. Stored in library JSON only — no standard ID3 frame, same as iTunes.)_
|
||
|
||
- [x] Search box over the library. _(Round 8.)_
|
||
|
||
|
||
|
||
### Rounds 1–4 (2026-06-12)
|
||
|
||
- **Spec 1–14** — full iTunes-replacement baseline:
|
||
- iTunes XML import with Mac→local location remap, play counts/ratings/dates
|
||
preserved, smart + system playlists skipped (`importers/itunes_importer.py`).
|
||
- Syncthing-friendly JSON storage, conflict-file merge on startup
|
||
(`storage/`); data dir lives in the synced music folder.
|
||
- iTunes-style layout: sidebar + track view; genre/artist/album browser
|
||
(Ctrl+B); per-playlist columns/sort/manual order; playlist folders.
|
||
- PyQt6 GUI; playback (mp3/m4a/flac via Qt Multimedia); scrubbing timeline +
|
||
transport buttons; Space/←/→ keys + MPRIS2 media keys.
|
||
- Drag/copy-paste tracks; drop-to-import files/folders; Get Info tag editing
|
||
via mutagen.
|
||
- **Round 2** — art paste in Get Info; BPM tap button; guessed ratings dropped
|
||
+ migrated; big-art window; 60fps EQ visualizer; cue-don't-play arrows;
|
||
now-playing speaker icon.
|
||
- **Round 3** — Preferences (`preferences.py`, Edit ▸ Preferences, Ctrl+,);
|
||
Last.fm scrobbling (`lastfm.py`, full-play-through only); theming
|
||
(`theme.py`: 5 highlight colors, 3 UI scales); shuffle (walk-order);
|
||
sidebar restructure (Library button + bottom art); MPRIS artUrl; painted
|
||
transport glyphs; drag fix; app icon + `install-desktop.sh`.
|
||
- **Round 4** — drag chip + glowing drop line; Library button border; 8px BPM
|
||
box; click-to-jump seek slider; vertical browser splitter; fixed-baseline
|
||
visualizer (this also resolved the "vibrating bar bottoms" complaint).
|
||
|
||
### Round 5 (2026-06-13) — UI polish + status bar + browser normalization
|
||
|
||
- [x] **Visualizer rounded corners** — rounded gray panel + 1px `palette(mid)`
|
||
border, bars clipped to the rounded shape (`gui/visualizer.py`).
|
||
- [x] **BPM button** flat inside its box, matching the transport icons
|
||
(`gui/transport.py`).
|
||
- [x] **Bottom status bar** — permanent totals readout `N tracks · DD:HH:MM:SS`
|
||
(trimmed leading units) for the visible track set; reflects browser
|
||
genre/artist/album selection and playlist contents; transient
|
||
import/scrobble/error messages still shown; height scales with UI size
|
||
(`gui/track_table.py` `format_total_time`/`tracks_changed`/`total_stats`,
|
||
`gui/main_window.py`, `theme.py` `status_height`).
|
||
- [x] **Browser normalization** — merge case variants (display the
|
||
most-tracks spelling; ties: more capitals, then alphabetical) for genres,
|
||
artists, albums; ignore leading "The" when sorting artists; case-insensitive
|
||
filtering so a canonical entry matches all variants (`gui/library_view.py`).
|
||
- [x] **Header bold bug** — `setHighlightSections(False)`; column headers no
|
||
longer bold when playback sets the current cell (`gui/track_table.py`).
|
||
- [x] **Library button** — light-gray rounded button, not bold
|
||
(`gui/sidebar.py`).
|
||
- [x] **Playlist title** — name only; count/time moved to the status bar
|
||
(`gui/playlist_view.py`).
|
||
- [x] Tests: `tests/test_round5.py` (format_total_time, _canonical_values,
|
||
_artist_sort_key).
|
||
|
||
### 2026-06-18 — top control panel tweaks
|
||
|
||
- [x] **Faster tap-BPM commit** — the tapped tempo now saves to the track 3s
|
||
after the cursor leaves the button, down from 6s (`gui/transport.py`
|
||
`BpmButton.SAVE_DELAY_MS`).
|
||
- [x] **Control panel redesign** — bar height floored at `BAR_HEIGHT` (84px,
|
||
~10% under the old ~95px) via `setMinimumHeight`, giving the center column
|
||
vertical slack; the title/artist block is centered with **equal** top/bottom
|
||
borders (verified ~11px vs ~10px) using equal `addStretch` above and below,
|
||
while the seek row stays pinned to the bottom so the timeline sits low. Key
|
||
fix: the **bpm box was moved out of the seek row** into its own box on the far
|
||
right (`layout.addWidget(_box(...))`). It was a 37px-tall box that inflated the
|
||
timeline row, floating the 15px slider with a lopsided phantom gap below the
|
||
artist; out of that row the seek row collapses to slider/label height so the
|
||
stretches split evenly. Note: `BAR_HEIGHT` is a *floor* — setting it below the
|
||
natural stacked height (~69px) has no effect. EQ visualizer panel background
|
||
now matches the transport boxes (`palette(alternate-base)` instead of
|
||
`window().darker(115)`). (`gui/transport.py`, `gui/visualizer.py`.) Verified
|
||
by offscreen `QWidget.grab()` render + widget-geometry measurement
|
||
(gnome-screenshot is blocked under Wayland).
|
||
|
||
|
||
|
||
|
||
|
||
- [ ] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` once trav
|
||
confirms the real import looks right (re-run import or copy `./data`, update
|
||
config with `--save-config`).
|
||
- [ ] Run the real import on trav's library and eyeball the result (fixture in
|
||
`./itunes-test-library/`).
|
||
- [ ] Album art beyond now-playing (grid/album view); embedded tags only
|
||
(decision: no parsing of iTunes' .itc artwork cache).
|
||
- [ ] Search box over the library.
|
||
- [ ] Background/threaded bulk file import (current import is synchronous).
|
||
- [ ] Better duplicate handling on import-by-drop (currently matched by path).
|
||
- [ ] Volume control in the transport bar (MPRIS exposes a fixed 1.0).
|
||
- [ ] Smart playlists, if ever (skipped at import for now).
|
||
- [ ] Wayland edge-drag resize — needs trav's verification; if still bad, try
|
||
`lintunes -platform xcb` and switch the .desktop Exec if that fixes it.
|