A Spotify track, album or playlist is read off Spotify's public embed
page, each song found on YouTube by the result closest to Spotify's
length, downloaded with the song flags, tagged with Spotify's
artist/title/album/track number, then imported and identified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pasting a song from a YouTube Mix (watch?v=…&list=RD…) imported song
after song: yt-dlp takes the whole list by default, and a Mix runs to
thousands of rows and loops back on itself, so the linked song came in
again and again with nothing on screen saying why.
Now anything but a plain song link is listed first (--flat-playlist,
streamed into the dialog). One song goes straight through. Several become
a checklist: a song-in-a-list link ticks only its song and offers Just
This Song without waiting, and a playlist link ticks everything. Rows are
de-duplicated by id. The chosen songs download by their own pages, never
the list link, and more than one gets a per-song progress window (Hide
keeps downloading; the status-bar line brings it back).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The play journals keep running totals per track, which is enough for "most
played ever" and nothing a Wrapped asks: top songs of this year, minutes
listened, which month, what time of day, what got skipped. Those all come
from one timestamped line per listen, so LinTunes now keeps that, while
Preferences -> General -> "Keep a play history for my yearly Wrapped" is on.
history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals:
local start time with its offset, ms actually heard (Player adds position
steps under 1.5 s, so seeks and pauses don't count), how it ended, where it
played and from what, and a name/artist/album snapshot that outlives a
delete. Under 5 s is noise and never written.
andTunes 0.3.0 writes the same lines on the Rabbit when sync says the
desktop wants them, and sync copies each month home when the device's copy
is longer. "Share this year's play history with friends" puts a trimmed
copy (no ids, playlists or devices) in each friend's cassette folder, daily
in November and December and monthly otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spectral-flux onsets + autocorrelation in numpy, decoded by the ffmpeg CLI;
no new dependency. Runs on a worker with the status-bar progress bar, for
one track or many, and the batch is one Ctrl+Z.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Wayland Qt paints the title bar through its adwaita decoration plugin,
which hardcodes pure white in light mode and ignores the palette. Every
window now goes frameless and gets an app-drawn bar in the palette's Window
color, so it follows the Background setting. Buttons follow GNOME's
button-layout; moving, snapping and edge resizing still go through the
compositor. Windows grow by the bar's height so their contents keep the
size they had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening Connections called find_device() on the GUI thread, which lists the
gvfs-MTP mount; on a wedged mount that blocks forever in an uninterruptible
FUSE wait and the app had to be force-quit. DeviceWatcher now probes on a
daemon thread (one at a time, STUCK after 3 s, late answers still land), the
menu reads its cached answer, and a stuck mount shows "Rabbit isn't
responding…" with a Reset Connection button (kill gvfsd-mtp, remount via
gio). Tests can no longer reach the real mount (conftest patches
find_device), which also takes the suite from ~200 s to ~22 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Rabbit Sync Settings shows the sidebar's folder tree. A folder has its own
tristate checkbox; a fully ticked folder is stored as device_sync.folder_ids
and follows the folder, so playlists added to it later sync too.
- The album-art pass compares covers against the sizes the planner's device
scan already read (plan.art_sizes) instead of an MTP stat per album, caches
"no art" as a .none marker, and only unlinks covers that are really there.
The summary reports covers actually updated rather than every cover.
- A quiet tink when a Rabbit sync, export or URL import finishes, with an
on/off switch (sound_effects, on by default).
- Preferences is now General / Appearance / Integrations, a list on the left
and the page on the right, opened big enough for the tallest page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row of boxes keeps one 16px gap before, between and after its
controls at every width; the now-playing panel and the seek row under it
take all the remaining width. The track lists get the same 8px of bare
window on the right that the playlists have on the left. README updates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The playlist list and the album art under it share one frame (the list's
own style, drawn around both), and the no-art placeholder is a shade
lighter than that frame line so the line still reads around it. The
playlist name, times and playhead sit 2px up (HeaderRow.LIFT), on the
Library button's bottom line by eye.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar gets 8px of bare window on its left, and the Library button
spans exactly the playlist list below it, scrollbar included. The Library
button, search box, playlist name, times and playhead all stand on one
floor 5px above the lists (text by its baseline). The top row floats:
equal space before, between and after the controls, with the now-playing
panel a fixed share of the width (700px at 1920). The header row grows if
the app font makes the Library button taller than the strip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The now-playing panel (and the seek row under it) caps at 700px, up from
560. The top row gets one equal gap before, between and after its
controls, capped at 28px; past that the extra splits between the two
ends, so the row sits centered instead of spreading out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Library button, the view's header (search box or playlist name) and
the seek row now share one band under the transport boxes, instead of the
seek row hanging alone with a header strip below it. The now-playing panel
caps at 560px and the gaps between controls share the rest evenly; more
room under the menu bar. Long playlist names shrink up to 2pt, then elide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The now-playing panel and the visualizer are debossed by exactly as
much as the buttons are embossed: the same rims and outline, flipped
(theme.deboss_qss / theme.paint_deboss), so things you press stand out
and things that show you something sit in.
- The bpm button's text is black, enabled or not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The emboss hugs the edges: thin lit and shaded rims, a defined outline
that's a touch darker along the bottom, and a nearly flat face — shape
without depth.
- Hover darkens the whole button slightly instead of Qt's pale panel on
top of it.
- One face for every button (theme.button_face_qss): regular buttons,
Library and its ▾, the purple friend-mode buttons, and the transport
boxes. Shuffle and bpm are boxes that are one button, so the whole box
darkens (bpm's whole box is now its tap target); the play box's three
tiles each darken on their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the features shipped since the list was written (Identify Track,
Import from URL, andTunes, art sources, New Tracks, merge improvements,
Cassette) without touching the existing lines, and a step-by-step guide
at the end: installing Syncthing, inviting a friend, choosing what to
share, cassetting songs, following playlists and troubleshooting. Icons
and screenshots in docs/cassette/ are rendered from the app's own
painting code against the synthetic dev library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A followed playlist (the UFO in friend mode) appears in my sidebar with
a purple UFO: mixed in with my playlists alphabetically, or inside a
folder named after the friend (per-friend, in Sync Settings). It's never
a Playlist of mine, so it stays out of the library and its merges.
- It plays from the cache. Player's queue accepts friend-track keys,
resolved through Cassette: they scrobble to Last.fm but don't count in
my play journal. Songs still on their way are grey and skipped.
- The cassette column there is hidden by default (right-click the
header); pressing one takes the song into my library — straight from
the cache if it's there, otherwise by asking for it.
- Sync Settings per friend: keep followed songs separate (default) or add
them to my library. Unfollowing (right-click) frees their cache.
- The bottom bar says when the friend's folder last synced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sending: a friend's requests ∩ what I still offer is copied into my
outbox (via a .part name Syncthing never sees); whatever they no longer
request is deleted, so the outbox cleans itself up.
- Receiving: a finished file I cassetted is imported exactly like Add to
Library — organized into Artist/Album, tags read, date added now, none
of their listening history — and a song wanted only for a followed
playlist lands in the cache. Then requests.json is rewritten without
what arrived and without what they stopped offering.
- Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a
file is whole, and an exact match already in my library is never
imported twice.
- Folders are watched (re-armed after each rename), with a slow fallback.
- file_importer split into stage_file (off the GUI thread) and
track_from_file (date added = now).
- pytest --syncthing now runs a real invite → share → request → deliver →
cleanup between two Syncthing instances.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A ▾ cut from the right end of the Library button (shown once there's a
friend) opens a popup of friends. Choosing one enters friend mode: the
button becomes "<Name>'s Library" in light purple, the sidebar lists
their shared playlists with a UFO follow toggle on each, and the content
area is their library — browse only, nothing plays or edits.
- A cassette column on the far left and cassette buttons on every artist
and album: pressed grey = send me this, medium grey on hover to let go.
An artist cassettes all its albums and tracks (a snapshot).
- Bottom bar: Hide tracks I have (exact matches: artist, album, title,
length within 2 s); close matches show light grey. Size of what's
cassetted against free space: orange when Syncthing would stall at its
free-space floor, red when it won't fit.
- Save and Close writes requests.json; Cancel discards; the ▾ or quitting
with changes asks Save / Don't Save / Cancel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sync Settings gets a share selector on each friend page (and on General
when "Share the same selection with all friends" is on; each friend's
own selection is kept, greyed, and comes back when it's turned off).
- Library: a genre/artist/album column browser with a checkbox beside
every artist, album and track, search, Select All / Select None and
"Share whole library". Playlists: a checklist with "Share all
playlists". Nothing is shared by default.
- Checking an artist or album is a snapshot of its tracks; the whole
library and shared playlists are live. A shared playlist's tracks are
checked and locked, with a tooltip naming the playlist(s).
- cassette/share.py writes the trimmed copy (library.json, playlists.json)
into each friend's folder: tags yes, listening history and local paths
no. Written on OK and after library edits (debounced), and only when the
bytes would change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Connections ▸ Share Library with a Friend… makes a one-time invite code
(device id, display name, token) and the inviter's send-only folder.
- Connections ▸ Add Friend Library… takes the pasted code: adds the
inviter, shares our folder, and accepts theirs receive-only when it's
offered back.
- Syncthing hides an unknown device's folders (verified on 1.30), so the
inviter probes a new knock only while an invite is open: added with
nothing shared, completed on the right token, otherwise removed and
never probed again. Used and cancelled codes go nowhere.
- Sync Settings lists open invites (Cancel Invite) and gives each friend a
page: avatar, plain-language status with a next step, last seen, last
synced, Remove Friend. Removals are staged until OK.
- scripts/cassette_pair.py runs throwaway Syncthings on loopback;
pytest --syncthing drives a real three-machine handshake.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First step of friend library sharing (Cassette):
- cassette/syncthing_api.py: a typed wrapper over the *local* Syncthing's
REST API. Key and address come from Syncthing's config.xml (config.json
may override); every failure is a SyncthingError naming the step, what
Syncthing said, and whether it's not running / a bad key / a refusal.
- cassette/state.py: friends, invites and share selections, kept in the
machine-local ~/.local/share/lintunes/cassette, never the synced data dir.
- cassette/host.py: one machine runs Cassette. config.json flags it,
preferences.json names it, and the other machine greys the menu group
out and says where friend sharing lives.
- Connections ▸ Sync Settings…: VLC-style list + page, editing a copy
that only OK writes. General shows Syncthing's status with a next step
for every failure, the display name and an avatar picker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The boxed controls were centered on the whole bar while the now-playing
panel hung from its top, so the bpm box sat ~11px below the panel beside
it. The panel is now CONTROL_HEIGHT tall and every control shares its top
edge; the seek row hangs under the panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking the gray sidebar art square (playing track has no embedded art)
now opens Download Album Art for that track, same as the right-click menu.
Once art is embedded the square refreshes and a click opens the big view
again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Multi-select Get Info keeps the art square; a pasted cover goes into
every selected track (shared cover shown, else "mixed artwork").
- Paste Artwork button + Ctrl+V anywhere outside a text field; a caption
says what happened. Clipboard reads that don't decode are retried and
never staged; every attempt is logged at INFO.
- Album art search queries Deezer alongside iTunes and ranks by album
match (iTunes has no copy of Digable Planets' Reachin' at all).
- embed_artwork shared by Get Info and Download Album Art; single-track
Get Info now invalidates the MPRIS art cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Import from URL now gets past YouTube's "Sign in to confirm you're not a
bot": a run that downloads nothing for that reason retries once with
yt-dlp's --cookies-from-browser (Firefox first), and a browser that worked
is remembered in this machine's config.json so later imports send the
cookies from the start.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Right-click a song -> "lookup on youtube" opens a YouTube search for
"<title> <artist>" in the default browser. Single selection only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking the already-selected playlist in the sidebar opens inline rename,
and renaming an existing playlist starts with the cursor at the end rather
than the whole name selected. New playlists still select all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single Right-arrow press skipped 1,676 tracks in 81 s and hard-froze the
desktop. On Wayland key repeat is generated by the client until the
compositor delivers the release, and the key filter treated every repeat as
a new press. Each skip sent gnome-shell an MPRIS Metadata whose xesam:artist
was a plain Python list, which marshals as "av" instead of "as". gnome-shell
logged an error for every one (50k/min) and reloaded the cover, and that load
is what kept it from ever delivering the release.
Space/Left/Right now act once per press and swallow the repeats.
xesam:artist goes out through mpris.string_array, which build_properties_changed
now shares for invalidated_properties. The new test round-trips the signal
into GDBus, which is what gnome-shell validates with, and fails 'av' == 'as'
against the old code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getevent settled what the wheel can and can't tell us: och1970_holl_key
advertises KEY_UP and KEY_DOWN and nothing else -- no REL_WHEEL, no
resolution, because the och1970 is a Hall latch the driver quantises into
clicks. There is no sub-detent position to read without a kernel driver
change, so all the nuance has to come from *when* the detents arrive.
So a detent is now an impulse into a velocity rather than a jump. The list
coasts and eases out, and an impulse is worth up to 8x more when detents come
30 ms apart than when they come 220 ms apart -- squared, so the gain stays out
of the way while you're hunting for one row. Measured on the device: one
unhurried detent moves about half a row, twelve fast ones move forty-odd.
Turning back the other way kills the coast first, so a correction bites
instead of fighting the glide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
trav couldn't play the first track of his playlist. Shuffle off gave him song
two; shuffle on gave him a random one. The player was right both times:
library.json named a song whose file was not on the device, and skipping an
unplayable file advances one slot. Tapping any song that *is* there plays
exactly that song, shuffle or not.
The file was missing because a gvfs-MTP mount can hold a phantom directory --
one it lists happily while the device has no such folder. Every write into it
fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it
never heals; only remounting clears it. That folder was new because Round 52's
retag moved the file under a new artist.
So a copy that raises OSError now costs that song, not the sync, and the song
is left out of the m3u and library.json. Aborting cost 2,400 songs for one
folder; counting it present anyway put a song in the manifest that isn't on
the device, which is the bug you could hear. The names come back in the
summary and a dialog rather than vanishing.
Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but
it walks every file on the device, and over MTP that is thousands of round
trips -- on the GUI thread it froze the window and GNOME offered to kill
LinTunes, which is how a sync got force-quit halfway through.
And the sync stopped going quiet at the end. "Album art 501/501" is emitted
before the last album, and then _write_index, _ship_buttons and
_prune_empty_dirs ran silently: 501 exists() stats over MTP, ~840 KB of
writes and a full tree walk, with the progress line frozen. They report now,
and _write_index reads Art/ in one listing instead of a stat per album.
andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly --
a detent adds to a pixel debt that a Choreographer callback pays off a
fraction per frame, so one detent eases to a stop and a fast spin blends into
one movement instead of teleporting a row at a time. Volume is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
The sync was fine. Everything it put on the Rabbit was there and correct --
2,432 media files, 208 covers, a manifest whose every field type-checks
against the parser -- and andTunes still answered "No library / Couldn't read
library.json". Fifteen tracks took all 2,439 songs down with them.
Library.group() keyed albumsByKey case-folded and artistsByName raw-case, and
only ever built the Artist inside `if (album == null)`. So the second track of
an album whose artist is spelled differently found the album already there,
skipped the block that would have made the artist, and dereferenced the null
that came back. Six artists in trav's library are spelled two ways: RJD2/Rjd2,
Toro Y Moi/Toro y Moi, FatBoy Slim/Fatboy Slim, LOVING/Loving, Land Of The
Loops/Land of the Loops, Salami Rose Joe Louis/salami rose joe louis. It had
worked until the 12th because that sync was the first to carry both spellings
of one of those albums.
Both maps fold case now, and the artist is fetched-or-made before the album
block and held, so no lookup left in group() can come back null. Artist gains
a key the way Album always had one, and ListActivity navigates by it --
ALBUM_ROW already passed album.key, so artists just stopped being the
exception.
The device filesystem is case-insensitive, so those two spellings are one
folder there. plan_andtunes_sync compared exact strings and saw every such
file as stale *and* missing, deleting and re-copying it over MTP on every
sync forever; the diff and the collision rule both fold now. plan.stale still
carries the device's own spelling, since that is what _delete_stale unlinks by.
Verified on the Rabbit: 2,439 songs listed, RJD2 one row of 12 songs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
The status bar's transfer widgets are permanent widgets: Qt lays them out
left to right but justifies the group right, so whichever is added last
owns the corner and never moves, and everything before it slides whenever
the group's total width changes. The label carries the song title of the
moment, so it changed width on nearly every track — dragging the ✕ across
the bar, which is the one widget you're aiming at.
So the ✕ is added last instead of first. It sits in the corner, the label
absorbs the movement, and the progress bar stops jittering too. Hovering
still expands it to "cancel transfer" and pushes the row left, which is
the one shift trav asked to keep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
trav's "Dionne Farris - I Know" kept identifying as Jay-Z, against ID3 tags
that plainly said otherwise. AcoustID was right; the ranking threw the answer
away.
The fingerprint matched one AcoustID result at 0.97, and six recordings hang
off it: Dionne Farris twice, plus Jay-Z, Marisela, New Atlantic and David
Essex, all of whom recorded a song called "I Know". A result's score belongs
to the *audio*, so every linked recording carries it however wrong the link
is. With the scores tied, ranking fell through to the duration bucket, where
Jay-Z's 222.7 s beat Dionne's 227.3 s against a 224 s file. The tags never got
a vote: the hint sat below duration in the sort key.
So the lookup now asks who submitted each link. `sources` joins LOOKUP_META —
475 people linked that audio to Dionne Farris, 6 to Jay-Z, 1 each to the rest
— and _link_tier sinks anything under a tenth of the strongest link in the
same result. The share is relative, never an absolute count, and a missing
count ranks as real: an obscure song's true link may have two submissions
against a stray's one, and rounds 45-46's payloads rank unchanged.
And it asks what the file already says. artist_hint_for gathers the artist
tag, the album artist and the artist in the filename; _artist_agreement counts
the words shared with a candidate's credit, placeholders dropped. Like every
hint since round 46 it only chooses among what AcoustID returned.
New key order: stray tier, artist agreement, duration bucket, hint overlap,
release rank — who, which take, which release. Artist above duration is the
whole fix; duration still separates two takes by one artist.
Verified live against the reported file: the proposal is now I Know — Dionne
Farris — Wild Seed - Wild Flower (1994), track 1, with all five mis-tagged
artists off the dropdown. The real response is pinned in tests/test_round52.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
andTunes phase 3. The R1 is now one more machine in the Round 38 play-count
model, and nothing lands on it that Android can't play.
Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes'
rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly
the per-machine-totals journal shape, written beside the old one and renamed
over it. Each sync first folds it into <data_dir>/plays/ with a new
play_journal.merge_totals, the per-track max: the file now has two homes and
both desktops may bring it back, so the max converges and an older copy can
never pull a count down. Nothing is written when nothing moved, and
PlayJournal.load needed no change.
Format gate: the planner reuses export/web_support.conversion_for outright,
since Android's MediaPlayer decodes the browser's set. FairPlay is refused
and reported; ALAC, AIFF and oddities land as FLAC, converted once into a
per-track cache and size-diffed after that. With no ffmpeg, the export's
"sync without them?" question. A file ffmpeg can't read costs that song,
not the sync.
"Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's
helpers stay because export and andTunes import them.
The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd
Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as
FLAC and played, the FairPlay track was refused, four plays came back on the
next sync and each track's effective count rose by exactly one, and a third
sync copied nothing.
Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which
anything touching the mount (find_device, the GUI tests) hangs in an
uninterruptible wait. CLAUDE.md now has the recovery.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
The Android half of andTunes had been paused on a 1 GB toolchain download
(Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed:
platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that
links no libraries needs five SDK steps, not a build system. andtunes/build.py
runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK.
andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a
streaming library.json parse grouped into artists and albums in one pass, one
ListActivity for every list (songs and artist songs open with Shuffle all,
albums carry art thumbs, search as you type), a Now playing bar under every
list, and a white Now Playing screen with square art, a slim scrubber,
prev/play/next, shuffle and repeat, and no back button. A foreground
PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and
a MediaStyle notification, end of queue pauses, missing files skip, and the
last queue and position come back on launch. Black on white, and every size
doubled for the R1's override density. Cold start to the menu: 313-338 ms.
The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not
volume. The app takes both in dispatchKeyEvent: lists scroll a row per
detent, and the menu and Now Playing get volume.
LinTunes side: Connections > Install andTunes on Rabbit... installs the
committed APK over adb and grants all-files access, or copies it to Download/
over MTP when there's no adb. Sync fills Buttons/ with default artwork only
where a file is missing, so trav's own art is never overwritten.
Verified against the dev library only, synced to the R1 and driven with
adb input + screencap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
File > Import from URL... runs trav's `song` helper (yt-dlp --extract-audio
--audio-format mp3) from inside the app. Everything at the link downloads to
a private temp dir and is imported as each song lands. "and add to current
playlist?" puts the batch above the selected song, else at the end of the
playing playlist, else at the end of the shown one, in the link's order.
Each song goes straight into Identify Track. With no AcoustID key, the
proposal comes from the filename alone.
Album art now opens on a single click, in a window shaped like the cover
and as big as the screen allows.
Also fixed the dev fixture's play journal, which crashed startup (wrong
shape) and would have been ignored anyway (wrong keys). The loader now
skips a journal it can't read instead of aborting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5m9mXFPHNro78BdD69mG2
Round 47 was verified by loading trav's real 21,531-track library. Nothing was
written and nothing was at risk, but it's the wrong habit and he said so. The
alternative is better for development anyway: reproducible, in-repo, and full
of the awkward cases on purpose rather than by luck.
scripts/make_dev_library.py builds a complete synthetic library — data dir and
music tree — from ffmpeg sine waves in six containers, with art on some albums
and not others, every playlist type (regular, folder, live/non-live/
unsupported/limited+nested smart, system, empty, duplicates), a play journal
and a tombstone. ~4 MB, gitignored; the generator is the artifact worth
keeping, not the sine waves.
The edge cases are the point. A collision pair identical in artist/album/title/
number, slashes and colons in every name, a track with no artist, a multi-disc
release, a compilation whose album_artist differs, a dangling location, a
208-character title, and non-ASCII plus an emoji all the way out to the m3u
filename. CLAUDE.md now carries the rule: never develop against the real
library, and when a feature needs a shape the fixture lacks, add it here.
Using it found two bugs in it — a relative --out made every location resolve
against the wrong root, and four-minute uncompressed clips made it 62 MB.
andTunes is paused on a weak connection (the Android SDK is a ~1-1.5 GB
one-time download; after that --offline builds need nothing). andtunes/
README.md now carries everything needed to resume cold: measured device facts,
the dp trap, exact toolchain commands, locked design decisions, and the
library.json contract. JDK 21 is installed and ticked off.
Also answers the scroll wheel question: it reads as volume because the ROM's
key layout maps the wheel's KEY_UP/KEY_DOWN to KEYCODE_VOLUME_UP/DOWN, but a
focused activity sees key events first — so andTunes can claim it by consuming
both those and DPAD_UP/DOWN, in onKeyDown and onKeyUp. No system file touched,
no other app affected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
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).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV