A player for the music already on your device. Point it at a folder and it reads the tags and the embedded album art out of your own files, builds a library you can browse, and plays it — with lock-screen and media-key controls.
Nothing is uploaded, and no account is needed. It is not a streaming service and has no catalogue of its own — it plays files you already have, and it cannot reach music that is anywhere else.
Three lookups, none of which does anything until you ask. The lyrics lookup asks lrclib.net for the words to a track whose own tags carry none, sending that track's artist, title, album and length. About this track asks Wikipedia about the song and the artist, sending the song's title and the artist's name. Both are off until you turn them on. Find a picture, on a request, sends the words you typed to Apple's iTunes Search, and only when you tap it. Nothing else goes with any of them, no audio ever leaves the device, and they are the only features that send anything about your music. See Lyrics, About this track and Requests.
There is no sign-in, on any device — website, desktop or phone. Nothing in Jukebox needs an account. What you made in the app (shelves, requests, settings, lyrics you added) is kept by Save a backup file and Restore from a file under Tune this app ▸ Backup, and the file goes wherever you put it. The app sends no open count or usage event either: nothing tells UNI·SIM when it is used. (Until 2026-10-20 the website and desktop app offered an optional Universal ID sign-in, which only added an online copy of that backup.) The iOS privacy manifest (
ios/App/App/PrivacyInfo.xcprivacy) and the store privacy answers must match this.
Live at https://opensource.unisim.co.uk/jukebox.
On the name. "Universal" is the prefix every app in this suite carries, and a jukebox is the ordinary word for a machine that plays records you already own. The pairing is only unambiguous while every surface says what the thing does alongside the name — so the page title, the description, the manifest, the suite catalogue entry and the front door all say "plays", "your own files" and "not a streaming service". Keep that when editing any one of them.
MP3, M4A/AAC, FLAC, WAV and AIFF — everything a current browser decodes on its own, which is why this app needs no decoder, no wasm and no worker. Ogg and Opus play where the browser supports them (most do; Safari mostly doesn't), so they work but are not advertised.
Refusing well is a suite convention: silence would tell someone their music is broken when the truth is that no browser has ever been able to play the file. Each of these is named on screen with a sentence.
| Refused | Why |
|---|---|
DRM-protected .m4p |
No browser can decode a licensed track, and neither can any other local player. |
.wma, .ape, .wv |
No browser ships a decoder. Universal Converter turns them into something this plays, also without uploading. |
.mid / .midi |
A score, not a recording — there is no audio in the file. |
Adding a folder ADDS to your library (2026-09-09). Before that it replaced it, and this section said at length why that was right; the reason it gave was a real standard rather than an excuse, and the feature had to meet it.
The app menu has one row for all of it, Your complete library · N songs
(James, 2026-09-13), which opens in place to list every folder with its own
song count and Remove, then Add tracks…, Add a folder…, Rescan
and, on the iPhone, Refresh my phone’s Music Library (my iPad’s on an
iPad). Only what each platform can
really do is offered, under a label that says it, and Add a folder… adds
everywhere: in a browser, and — since 2026-09-14 — in the iPhone and Android
apps too (addNativeFolder). Until then the phone apps had one folder, choosing
another replaced it, and the row honestly read Use a different folder….
Replacing a folder is now what it is on the web: Remove on its line, then
Add a folder…. Add tracks… appears only where picked songs are copied in
and kept (the iPhone app's own folder). Removing a folder takes its tracks and
leaves the others untouched. Rescanning is a replacement of each folder, so an
album you deleted on disk disappears and a renamed file does not turn up twice.
Each phone folder is its own root, filed under its own prefix exactly like a
browser folder, and its Root.nativePath says where it is walked: '' is the
app's own folder (the Files app's Universal Jukebox, iOS only), anything else
a chosen folder's uri. The native plugins hold one grant per uri — an iOS
security-scoped bookmark (MusicFolderPlugin.swift, re-taken for every folder
at launch) or an Android persisted tree-URI grant (MusicFolderPlugin.java) —
so the rules that keep that safe are all in lib/roots.ts:
- The same folder picked again is a rescan, never a second root: the phone
can tell (same uri), unlike a browser. The iOS picker answers
''when you pick the app's own folder, so that one is recognised too. - A different folder with a name already taken is kept beside it as "Music (2)" (James) — the same rule as the web, below.
- A grant is given back only once no root reads it (
unusedGrants) — a folder removed, a new one that held no music, a scan deleted part-way, or a lost folder's old uri once it has been chosen again. - A chosen folder that can no longer be read — moved, renamed, access withdrawn — is named on its own in the permission banner, and its Rescan opens the picker. A folder of the same name chosen there takes the root over, keeping every track id; any other folder is added, and the lost one stays.
- Nothing to migrate. The one-folder build stored one root with a prefix and
a
nativePath, and its grant was already kept under that uri — so it simply becomes folder #1. Nobody picks their folder again.
trackKey is path + size + mtime, and the paths a scan produced used to be
relative to the chosen folder with nothing in front of them. Two folders both
holding Nick Cave/Let Love In/01 Do You Love Me.mp3 — an original and a
backup — therefore minted the same track id and the same filesByPath key.
One silently overwrote the other, and which one won depended on the order the
scans finished in. With one folder that could never happen; with two it happens
on the first day.
So every path now carries its root's name — Music/Nick Cave/… — which is the
shape webkitRelativePath has always had, so the two walkers agree for the first
time as well. Root.prefix is the root's identity: it is what the user sees
in the folder list AND what every one of its tracks is filed under, which is what
makes "remove this folder" a filter rather than a bookkeeping exercise. Prefixes
are therefore kept unique: a second folder called Music is added as
Music (2) — two folders that share a name are kept, both of them (James,
2026-09-13). The only re-picks that mean rescan that one are the ones known
to be the same folder: the phone apps' same uri, and a stranded folder chosen
again by name through its own Choose folder button.
album fix from
the tidy-up — which is keyed by track id — no longer matches its track and is
dropped. applyFixes ignores fixes it cannot place, so nothing breaks; somebody
who had merged tracks by hand has to do it again. Cover fixes are keyed by album
id, which comes from the tags and is unaffected.
All of the arithmetic is pure and in lib/roots.ts, with 43 tests, because
every failure here is silent: a collision overwrites rather than throwing,
and a track count that is added up rather than recomputed is just a wrong number
on a tile.
One card, with a row per unreachable folder naming that folder and carrying its own button. The explanation and the "Start a new library" escape hatch are said once, above and below the list, because they are true of the library rather than of any one folder; only the name and the button repeat. A whole warning card per folder — which is what shipped first — read as several separate problems and pushed the album view underneath it off the screen.
The button cannot be merged, and that is the part to leave alone. A single "Allow access" that looped over the folders would fire several permission prompts inside one user gesture — which browsers may collapse into a single grant, silently leaving the rest unplayable under a banner that has just disappeared.
Which folders are stranded is derived from the live File map
(needAccessFrom), never stored. It was a needsRegrant boolean, and that was
fine with one folder and a lie with two: re-granting one of three would have
cleared it for all of them.
useMemo.
A scan used to store tracks batch by batch and its root only at the end, so an
interrupted first scan (window closed, read failed, superseded, a reinstall
mid-scan) left every track filed under a prefix no root owned. Nothing then said
so: with no root, the permission banner had nothing to name, and the player's
"isn't reachable" error is hidden by default. James's installed app sat like that
with 2,960 tracks and zero roots (2026-10-08, 46619b7).
So runScan persists the root before the first batch and keeps it if the read
throws, and hydrate repairs libraries already in that state: orphanRoots
(lib/roots.ts) rebuilds one root per top-level path segment with no handle,
which the banner offers as Choose folder. Web and desktop only; the phone
apps hold their own grants. Choose folder uses showDirectoryPicker where there
is one (chooseFolderAgain), so the handle is kept and the next launch needs
only Allow access. And ScanBanner drops a folder's row for a player error
covering it only when errors are shown: standing down for a message the user
never sees is how this stayed silent.
a03c301). scan()'s onBatch now
receives the live files map, and every publish hands over the files found so
far. In the same commit, Choose folder (picker and webkitdirectory alike)
stands its banner row down while the folder is read, as Allow access already
did, rather than still asking for a folder that is being scanned.
A track's length is not in the tags and scan.ts decodes nothing, so a fresh
library showed "—" in every Length column until each song had been played once
(James, 2026-10-08: "can we get the duration before having to play the
tracks?"). lib/lengths.ts walks the tracks that have a live file and no
durationSec, one at a time, through an unplayed <audio preload="metadata">, writes each with db.setDuration, and republishes the
store in batches every 2.5 s. One at a time because Chromium caps a page's media
players and the two decks need theirs. On a copy of James's 3,327-track library
it went from 1 to 2,116 lengths in 60 s (about 35 a second) while a song played
uninterrupted (12fd791).
addScan now carries durationSec across to a rescanned track
with the same id (same path, size and mtime), and runScan's snapshot picks up
lengths learnt while the scan was running. The walk also runs in the phone
apps; that has not been tried on a phone.
Nothing to hand? The landing page offers an example library: nine records
by four artists who do not exist, with the music generated in the browser
(lib/exampleLibrary.ts). No audio is shipped or downloaded — which is the
only honest way for this app to have a demo, since bundling real music means
licensing real music, and an app whose pitch is "it plays your own files"
should not quietly fetch somebody else's. The sleeves are CC0 photos with the
album name set on top (src/assets/example-covers/, sources in its README),
in a chunk of their own that loads only with the example and is left out of
the offline cache; without it the drawn sleeves stand in.
It says it is the demo, once you are inside it. The sleeves look like real
records and survive a reload, so a dashed Example library
note sits above the library, the album pages and Now Playing while it is loaded
(ExampleNotice). It is a label, not a door: there is still no way to load the
demo from a real library, because loading it replaces the library and that would
need a confirmation and a way back that do not exist.
Three things about it are deliberate:
- The index is built eagerly, the audio lazily. Titles, years, durations and artwork appear at once; the WAV for a track is synthesised the first time something tries to play it, and at most four are kept.
- It is deterministic. Every note comes from a PRNG seeded with the track's own path, so a record sounds the same next time — and, more usefully, still plays after a reload, when the library has come back out of IndexedDB with no files anywhere to point at.
- Two of the four artists have three or more records, because three is where the album grid folds a run into a fan. A demo library that showed none of the grouping would be missing the part worth showing.
addTone ran its "has this note died away" test during the attack, where the
envelope is exactly 0 — so it broke out of the write loop on the first
sample of every note and the pad, the bass and the lead wrote nothing at all.
What survived was the drums, which are built by a different function. Eight of
the nine records played as percussion only, and "Quiet Rooms" — the one record
with drums: false — was thirty-eight seconds of digital silence. Fixed
2026-09-09.
The reason it lasted is worth more than the fix: every file was the right
shape. Correct duration, decoded cleanly, played to the end, fired ended on
time. The unit tests, the typecheck and every browser run through the app agreed
it was working, because none of them looked at the content. If you change the
synthesis, check it by measuring — the PCM peak of a generated track, per
voice, not per file. The whole-file peak was healthy the entire time the melody
was missing, because the drums were never affected.
That measurement is now a test: lib/exampleLibrary.test.ts renders every voice
of all 31 tracks on its own and holds each to a peak and an RMS floor. With
the old unguarded envelope put back it fails 93 times — the pad, the bass and
the lead of every track — while the whole-track peak of the 27 tracks with drums
stays above both floors, which is the point: a per-file version would have
caught "Quiet Rooms" and passed the rest.
This is the honest bit, and the app is designed around it rather than discovering it later.
| Where | What you get |
|---|---|
| Chrome / Edge | Pick the folder once. The directory handle is stored, so the library is still there next launch behind one permission confirmation — and in Chrome 122+, installed as an app, not even that: an installed app keeps its grant. The landing page says so only in Google Chrome 122+ that is not already installed (lib/persistence.ts); Edge shares the engine but does not document the policy, so it is not promised there. |
| Firefox / Safari | Pick the folder every session. Neither ships File System Access, and the permission is the thing that cannot be saved — no polyfill can invent it. |
| iOS app | Its own folder needs no picking — the OS shares it with the Files app. Other folders (iCloud Drive, a drive, another app's) can be added in the system picker, as many as you like, and each is kept across launches. |
| Android app | Pick each folder once, in the system picker — as many as you like. Android keeps each grant, so the library is still there next launch with nothing to confirm. |
On all three, the library and the artwork survive, cached in IndexedDB and keyed by path + size + mtime. What a Firefox visitor loses is a click, not their library: the covers are already there and nothing is read twice. The button and the copy differ per platform rather than failing at the moment of use.
showDirectoryPicker does not exist in an iOS WebView and webkitdirectory is
ignored by iOS Safari — an <input> carrying it quietly degrades to picking
single files. A straight wrapper of this app would therefore install, launch,
look completely correct, and have no route to a single track. The button would
be there and it would do nothing.
So the native build stops asking. UIFileSharingEnabled +
LSSupportsOpeningDocumentsInPlace publish the app's Documents directory to the
Files app as a folder called Universal Jukebox; music is copied,
AirDropped, unzipped or synced into it, and the app walks that. Sub-folders are
kept. There is also an in-app Add music picker — a plain multi-file <input>,
which iOS does support — and it copies what you pick into that folder rather
than holding the picked File objects, because those are ephemeral and a
library built from them would be empty after a relaunch.
readdir per directory and
nothing else.
ensureNativeMusicFolder() writes a
short Put your music in here.txt on first run, which makes the folder appear
and says what to do with it. It is written only when the folder is completely
empty, so it never returns once there is music in there, and it does come back
if the folder is emptied — which is the one moment it is wanted again.
The iOS answer does not carry over. @capacitor/filesystem's
Directory.Documents on Android is not an app folder: it is the phone's shared
/storage/emulated/0/Documents, and under scoped storage (Android 11+) an app
can list only what it wrote there itself. Measured on an Android 15 emulator: an
MP3 put into Documents from outside was invisible, and so was every file under
/sdcard/Music. The first Android build compiled, installed, told you to use a
"Universal Jukebox folder in your Files app" that does not exist on Android, and
could not have found a single track.
So on Android Choose your music folder opens the system folder picker
(Storage Access Framework, starting in Music) and the app keeps the grant
(takePersistableUriPermission). The folder's tree URI is stored as the root's
nativePath — a plain string, like the iOS path — so the library comes back on
launch with nothing to confirm, and a cover image beside an album is visible
too. An app-local plugin (android/…/MusicFolderPlugin.java) picks and walks
the folder; the bytes are still read through Capacitor's local server, never
through the plugin. Add a folder… adds another, with its own grant — see
Several folders in the phone apps above.
The in-app importer is hidden on Android, because it would copy into that shared
Documents folder, which the library does not read.
The chosen-folder route is switched on by the plugin, not by the platform name
(usesChosenFolder() in lib/nativeFile.ts), so a native plugin of the same
name on another platform turns it on there.
206 with a Content-Range for exactly the bytes asked for, and a
body that runs on to the end of the file: asking for 512 KB of a 20 MB file
returned all 20 MB. The tags still parse, so nothing looks wrong. The scan would
have moved every file in the library through memory. NativeFile therefore
reads the body as a stream and cancels it once it has the window, which brought
that read down to about 1 MB of actual disk reads.
Received wisdom is that HTMLMediaElement.volume is read-only in an iOS
WebView: the assignment does nothing, the property reads back as 1, and volume
belongs to the hardware buttons alone. Four things here go through
element.volume — the slider, mute, the fades and the crossfade — so if that
were true on the target device, all four would stop working silently.
It was not true. Measured on an iPhone 15 Pro running iOS 26, in this app's own WebView: the assignment round-trips, so the fades and the real crossfade run on the phone exactly as they do on the web.
lib/volumeSupport.ts asks whether the engine stores the value it was given.
Whether the audio path then attenuates by it cannot be read from JavaScript —
the only instrument for that is an ear.
The check stays, because of what it protects when the answer is no. Three of
those four features would merely stop being heard. The crossfade would get
worse rather than absent: it starts the incoming track under the outgoing
one, and with no working gain "under" is full volume, so both records would play
at once for the length of the fade. Where volume cannot be set, the crossfade
degrades to a clean change-over, the fade sliders are disabled with a sentence
saying why, and the transport's volume slider is replaced by a line saying the
volume is on the device's buttons. Mute stays: it goes through
element.muted, which those engines honour.
if (isIOS) noFades() — would have
left this app refusing to fade on a device perfectly willing to, with a confident
comment explaining why and nothing anywhere to contradict it.
<audio> at all; see the next section.
Since 2026-10-11 the iPhone app's sound comes from JukeboxEngine
(ios/App/App/JukeboxEngine.swift). It uses two AVPlayers for the two
decks, and they have a gain iOS actually applies. lib/audio.ts keeps its
interface, and in the iOS app every export hands over to lib/audioEngine.ts,
so the player store, the deck animation, the lyrics timing and the UI do not
know the difference. The web and Android keep the <audio> decks, untouched.
- The engine runs the queue while the phone is locked. Probe 3
(2026-10-07) found that a native player keeps playing and keeps the app
alive, but the page's JavaScript stops within a second of the lock. So the
store keeps sending the engine the next stretch of the queue, with every
change-over already decided (
setUpcoming, built fromlib/enginePlan.ts). That covers the crossfade or record change into each song, its Stable-volume level, its quiet intro and dead-air ending, and the start-up cue for its machine. While the app is not active, the engine does what the store would have done: the early crossfade, the next song, the fades, the sleep fade and the lock-screen buttons. It also measures songs the page never reached. When the app is active the page drives exactly as before, and the engine only plays what it is told. - Waking up. Every change of song the engine makes by itself bumps its
seq. The page then fetches the engine's state and moves its cursor to the song the engine reached, with no animation. Page commands from an olderseqare refused, so a timer firing on waking cannot drag the music back. - It owns the audio session, so nothing in the page may play. Two sessions
in one app interrupt each other. The 10-second preview and the start-up cues
go to the engine too, and the Web Audio graph is never built (Quiet is a gain
in the engine).
AppDelegate.swiftexplains why the old rule was the reverse. - The lock screen is the engine's.
NowPlayingPluginstill draws the turning record, but hands it to the engine instead of writing the entry. The remote commands go to the page while the app is active and are handled natively while it is not. - Limits. Gain above 1 is clamped: the Loud half of "Quiet or loud" can only bring a lowered slider back up to full, and the 2× boost stays off on iPhone. The example library's songs are synthesised in the page, so only the next two are written out for the engine, and a locked phone runs out of demo after them.
The native shell runs viewport-fit=cover with contentInset: 'never', so the
page owns the full screen — the Dynamic Island and the home indicator included.
Two rules pay for that, and both are invisible on a Mac, because
env(safe-area-inset-*) is 0 in every browser and every emulator.
pt-[env(safe-area-inset-top)]on the page wrapper inApp.tsxpb-[env(safe-area-inset-bottom)]on the transport inPlayerBar.tsx
UniversalAppsNavBar is position: sticky; top: 0 and takes the inset onto
itself as paddingTop, cancelling the wrapper's with an equal negative
marginTop. With no wrapper padding to cancel, the bar's natural box top is
above the viewport top — so sticky engages immediately and pins the box at
y=0, while the flow below has reserved only the un-padded height. The bar ends
up inset pixels taller than its own gap and covers the top of <main>.
Measured on the device that reported it: inset 59px, navbar 63..122, and the
landing artwork at y=88 — 35px of the record under the bar. With the rule, the
artwork sits at 147 and nothing overlaps.
cd D:/Github/UNISIM/Universal_Apps/Universal_Jukebox
npm install
npm run dev # http://localhost:5204npm test # unit tests (vitest)
npm run test:tags # the tag + cover-art reader, against real files
npm run lint
npm run buildThe iPhone, iPad and Android apps have screens of their own, in src/native/:
a tab bar (Artists, Albums, Tracks, Shelves, Tune), what's playing docked above
it, and the record full screen. There is no navbar, suite switcher or sign-in
in the phone apps, and the iPhone app never looks lyrics up online
(barsOnlineLyrics in lib/nativeFile.ts says why). Now Playing, the shelves
and Tune this app are the website's own components inside the phone's shell.
The website and the Windows app never load that folder.
To see the phone screens in a browser, run npm run dev and open the page with
?app on the end (?app=0 goes back). Files then come from the browser's own
pickers, since the native plugins aren't there.
cd D:/Github/UNISIM/Universal_Apps/Universal_Jukebox
npm run cap:sync # build --mode desktop, copy into ios/ and android/, then verify
npm run cap:open:ios # Xcode
npm run cap:open:android # Android Studiocap:sync, never a bare npx cap sync. The hosted app lives at
opensource.unisim.co.uk/jukebox/, so a production build asks for
/jukebox/assets/… — and Capacitor serves the copied bundle at the ROOT of its
own origin, where no /jukebox/ exists. Every asset 404s, no module script
runs, and the result is a white screen that Xcode reports as BUILD SUCCEEDED.
--mode desktop is the build that gets this right, and
scripts/verify-mobile-bundle.mjs (which cap:sync runs) fails loudly if the
wrong one was copied. This has shipped for real elsewhere in the suite.
Store releases (1.0.0, 2026-09-27): the version lives in package.json
only. npm run sync:ios-version stamps it into Xcode (build number
major×10000 + minor×100 + patch), and Android's build.gradle reads it
itself. npm run android:release builds the Play bundle signed with Universal
PDF's upload key, as every suite app on Play must (see the script's header).
Until 2026-09-27 the Android app had no media session and no foreground
service: no lock-screen or notification controls, no Bluetooth buttons, and
nothing keeping the process alive once the screen went off.
MediaPlaybackService (driven by NowPlayingPlugin, which registers as
JukeboxNowPlaying with the iOS plugin's methods so lib/nowPlayingNative.ts
drives both) fixes that. It plays nothing: the sound is still the page's
<audio>. The service is a mediaPlayback foreground service that keeps the
process alive, plus a MediaSession whose buttons reach the page as commands.
- It never requests audio focus. The WebView already holds focus for its
<audio>and pauses on losing it, which covers calls and other music apps. The first build took focus itself, and the WebView paused every song 2 ms after it started. - It is started in the app and stays up through pauses and track changes, until the queue ends or the app is swiped away. Android 12+ refuses to start a foreground service from the background.
- The Play Console needs the
mediaPlaybackforeground-service declaration (App content) before a release.
Unsupported class file major version 69.
cd D:/Github/UNISIM/Universal_Apps/Universal_Jukebox
npm run build:desktop # the same --mode desktop bundle the phones get
npm run electron # the desktop shell, against that build
npm run dist:win # the NSIS installer, into release/ — on WindowsAn Electron shell (electron/main.cjs) around the --mode desktop bundle,
loaded from disk. The music is read exactly as the browser reads it — through
Chromium's own folder picker — so the renderer stays sandboxed and the preload
exposes only the SDK's hub handoff and Jukebox's withGesture (below). One copy runs at a time (a second
would share the library's IndexedDB), links out open in the system browser, and
background throttling is off so a change-over keeps time while minimised.
The installer is built on GitHub, not on a Mac: Actions → windows → Run
workflow. electron-builder writes the icon into the .exe with rcedit, a
Windows program, which a Mac can only run under Wine. The run smoke-launches the
packaged app and keeps the installer as its artifact for 30 days; give it a tag
and it is attached to that GitHub Release as well. Unsigned, like the suite's
other desktop apps, so SmartScreen asks once: More info → Run anyway.
ELECTRON_RUN_AS_NODE, and with it set any
Electron app starts as plain Node and exits at once. Launch with
env -u ELECTRON_RUN_AS_NODE.
James, 2026-10-08: "Can't we have a permanent permission so the allow access is
automatic?" A chosen folder's FileSystemDirectoryHandle survives a relaunch in
IndexedDB, but its read permission goes back to prompt, and Chromium's
requestPermission throws SecurityError: User activation is required without
a gesture. Electron then grants the request itself with no dialog, so the
Allow access click bought nothing.
So (2479bfd): the preload exposes unisimDesktop.withGesture, which invokes
IPC jukebox:with-gesture; main.cjs runs window.__jukeboxGesture() through
webContents.executeJavaScript(code, true), whose true is a user gesture. The
page queues its requestPermission calls behind that (requestWithGesture in
libraryStore; after 20 s, since 52108b3, it falls back to the banner). Then reattachFolders
walks each folder for its files with filesUnder (scan.ts): a tree walk, no
tags read, the desktop twin of the phones' native reattach. In a browser the
folder comes back on load only if permission is already granted. Any refusal
means the banner, as before.
page.evaluate and a check run from
addInitScript reported navigator.userActivation.isActive === true, so they
showed requestPermission succeeding with no click: a false pass. The honest
test is to launch electron.exe directly with --remote-debugging-port, attach
over CDP afterwards, and check with Runtime.evaluate (userGesture: false).
That way, a copy of James's profile opened with no banner, learnt lengths
(2,549 to 2,749 in 5 s) with no interaction, and played a track. Before the
bridge, the same launch showed the Allow access banner.
2b3481f; James: "I see the banner and then it
disappears, can it just not be shown"). The library renders from cache before
reattachFolders / reattachNative finish, and the banner is derived from
tracks with no live file, so it flashed up in that gap. hydrate now sets a
reattaching store flag when any root has a handle or a nativePath, and
clears it in a finally. ScanBanner hides its permission rows while the flag
is set. A MutationObserver installed before first paint saw the banner at about
536 ms on the previous build and never on this one. Because the flag is cleared
in the finally, a refused reattach should still bring the banner back once it
gives up (up to the 20 s gesture timeout), but that path has not been driven.
executeJavaScript(code, true) does not reliably give a user gesture while
the page is still loading (45aaef4). On a real launch of the no-click build,
James still saw the Allow access banner. The page now keeps a trace,
window.__jukeboxReattach. Read over a debugging port on his real profile, it
showed that about one launch in four had requestPermission throw "User
activation is required" even though main.cjs ran it with userGesture = true.
It always happened early, at about 0.5 s. (52108b3's commit message guesses
that Windows was scanning the new .exe. That guess was wrong, though the
20 s timeout stays.) The fix has two parts:
main.cjs waits for did-stop-loading (if isLoading()) before
executeJavaScript, and the page retries a SecurityError up to 5 times,
400 ms apart. It also waits for the window to be visible, but that is only a
guard: the failing launch was already visible. Across 8 install-and-launch cycles
on his profile, all 6 that started opened with no banner, including one that hit
the race and recovered on retry (trace: "retry after … User activation is
required", then "granted" at 896 ms). The other 2 never started because the
previous copy still held the single-instance lock. That was the test harness,
not the app.
src/lib/tags.ts is the half of this app that fails silently. A misread tag
is not an exception — it is an album filed under the wrong artist, or a cover
that decodes to nothing and draws as an empty square. Both look exactly like
"that file must not have any artwork".
So npm run test:tags reads real music files in src/lib/__fixtures__/ and
asserts on known bytes. Those fixtures are written by
mutagen — the tag library behind picard,
beets and quodlibet — and Pillow, via scripts/make-fixtures.py. That is the
whole point: a round-trip through our own writer only proves that two halves of
one misunderstanding agree with each other. The fixtures are committed, so CI
needs neither Python nor mutagen.
To add a case:
pip install mutagen pillow
python scripts/make-fixtures.py # then commit what it writesWriting these caught two real bugs before the app existed: a minimum-size guard
that silently rejected any cover under 100 bytes, and an MP4 disk atom floor
that accepted trkn and dropped disk from the same file — presenting as "my
library has no disc numbers" rather than as any kind of error.
The lyric frames are in the same fixtures for the same reason, and the text in
them is doggerel written for make-fixtures.py: a fixture has to assert on
an exact string, and the only words that can be committed to a public repository
and compared byte for byte are words nobody owns. It also makes a failure
obvious, since nothing in them could be mistaken for a real song.
src/
├── lib/
│ ├── tags.ts # ID3v2 · MP4 ilst · Vorbis comments, + cover art. Pure, no DOM
│ ├── keys.ts # what counts as the same file, and the same album
│ ├── roots.ts # several folders: prefixes, merging, removing. Pure, tested
│ ├── search.ts # what the search box matches — and so the tab counts too
│ ├── scan.ts # the folder walk — header-only reads, streaming results
│ ├── lengths.ts # every track's length, learnt in the background, one probe at a time
│ ├── library.ts # IndexedDB: tracks / albums / roots / fixes / lyrics
│ ├── lyrics.ts # LRC in, timed lines out — + the on-demand read. Pure, tested
│ ├── lrclib.ts # ⚠️ ONE OF THREE FILES THAT TOUCH THE NETWORK (aboutTrack.ts, requests.ts). Off by default
│ ├── requests.ts # music to get: matching against the library, + the tap-only iTunes picture search
│ ├── art.ts # extract → downscale → cache → object URLs (bounded)
│ ├── audio.ts # two <audio> decks, the crossfade, a real shuffle, the fades
│ ├── audioGraph.ts # the OPTIONAL Web Audio graph — boost + analyser. Read it first
│ ├── ceremony.ts # when the record-changing animation runs. Pure, tested
│ ├── transition.ts # what happens BETWEEN two tracks: blend or record change. Pure, tested
│ ├── exampleLibrary.ts # the demo library — generated music and sleeves, no assets
│ ├── tidy.ts # the tidy-up rules. Pure, and mostly about what it refuses
│ ├── crackle.ts # the four synthesised start-up cues — no asset, no licence
│ ├── decks.ts # the decks as WORDS, + the Random rotation. Pure, tested
│ ├── applySettings.ts # the one place settings become audible
│ └── mediaSession.ts
├── stores/ # playerStore (owns the ceremony timeline) · libraryStore
│ # · settingsStore · tidyStore · themeStore · lyricsStore
└── components/ # Landing · AlbumGrid · AlbumView · CoverFan · OpenGroup
# · Deck (the frame) · decks/ (Vinyl · Cd · Cassette · Jukebox)
# · NowPlaying · UpNextReel · PlayerBar · Lyrics
# · PreviewButton · Settings · Tidy
- Nothing may call
file.arrayBuffer(). A 5,000-track library is 40 GB. Every read inscan.tsisfile.slice(a, b).arrayBuffer()— a range read off disk.arrayBuffer()is the obvious method and it works beautifully on the three files anyone tests with. - One cover per album, downscaled. 5,000 tracks × a 500 KB embedded JPEG is 2.5 GB. Art is extracted only for the first track met from each album and stored at 512px, which takes the same library to roughly 16 MB.
tags.tsis a port, and its twin isUniversal_Apps/Universal_Converter/src/lib/tags.ts. A parsing change in either belongs in both. When a third app wants tags, extract it into@unisim/mediarather than taking a third copy — the failure this is copying towards is a change over there rendering a different picture here, with nothing anywhere raising an error.
The turntable animation — the record is lowered onto the deck, fading in from just above it, the platter spins up, the tonearm comes down, a 3 · 2 · 1 counts beside the deck while the first bytes come off disk, and a synthesised needle drop as the arm lands.
The arrival is one CSS animation on the medium alone (arrival in
components/decks/face.ts) rather than on the whole deck: the record player has
to still be there while the record arrives. It is also what makes the record
CHANGE possible without any face holding two covers at once — the old record
fades out, the artwork underneath swaps while nothing can see it, and the new
one fades in.
By default it runs on every play (James, 2026-09-08). Press play on a track,
an album or a search result and you land on the deck and the arm is cued. Only
on an explicit start, though: advance() (next, previous, the natural end of
a track) never asks, so a running queue is never interrupted by the full
ceremony — it gets the shorter needle change below instead.
There was a 90-second cooldown, and it is now zero — CEREMONY_COOLDOWN_MS
in src/lib/ceremony.ts, deliberately off so James can find the real limit by
ear. That constant is the only place a rate limit lives; put 90_000 back and
the old behaviour returns exactly, with the tests in ceremony.test.ts (which
pass their own cooldown, so they go on proving the knob while the shipping value
is zero) to say what it should do.
Any click or key skips it, prefers-reduced-motion drops it entirely (the arm
is simply down and the music starts), and there is a Don't show this again
on the animation itself as well as a slider in Settings along a frequency
ladder — every track · when the album changes · when the artist changes ·
once per visit · never. The same setting governs the change-over between
tracks and how often the start-up sound plays; the ladder's order lives in
CEREMONY_LADDER and nowhere else.
Its timeline lives in playerStore, not in the Deck component — the deck
is only mounted on Now Playing, so a ceremony owned by it never finished when
you pressed play from an album.
Both file lookups in playerStore move the cursor before they check the
file. That is deliberate: the error then names the track you asked for rather
than the one that was already on. But it means the state has already moved when
the lookup fails, and for a while each one simply set error and returned.
Nothing told the <audio> element. The title changed, the level bars beside it
kept animating, the scrub bar kept moving — and a different song went on
playing underneath a message saying it could not be played. That reads as a
bug in the message, which is the one reading that leaves nobody able to fix
it.
unreachable() takes the whole transport down: the ceremony and change-over
timers (which would otherwise fire into the wreckage), audio.stop(), the OS
media card (which would otherwise still offer play/pause for a track that is not
on), and previewTrackId, since stopping the sound also stops a preview and its
button must not be left saying "stop" over nothing.
Anything added to an error path here has to answer the same question: what
was the old state driving, and who is going to tell it? A set({ error }) and a
return is a label on a machine that is still running.
It does act on the reason (2026-09-10; it used to say one sentence for
both). useMissingFile reads the reason from the library rather than storing
it: a folder with no live files has lost its permission, and the error
carries that folder's own button — FolderAccessButton, the same one the
permission banner uses, which drops that folder's row while the error is up so
it never shows twice. A folder whose other files are fine has lost this
file, and the error says so, with nothing to press. Once the folder is back
the error offers Play it, because unreachable() left nothing loaded for
the play button to resume.
addFiles: before that,
re-choosing a stranded folder filed it as a new "Music (2)" and left the
original asking for itself forever. It rescans into the root only when the
chosen folder carries that root's name (isFolderNamed); anything else is
added as a new folder.
Decided (James, 2026-09-13): "Add a folder…" with a name you already have
adds "Music (2)" — keep both — in the browser and in the phone apps.
uniqueLabel's comment used to claim an exact match meant "rescan that one";
it never did, and it now says what the code does and why.
The arm is not decoration after the landing. It creeps inward across the
record as the track plays — the outer groove to just outside the label, driven
by currentSec / durationSec — and between tracks it lifts, goes back out to
the start, and lands again with the scratch on top.
Four things about that are worth knowing before changing it:
- The arm is two nested rotations about the same bearing, not one sum. Where on the record the needle is (slow, linear) and the arm being lifted and cued (springy, overshooting) need different transitions; added together they would have to share one, and either the landing crawls or the creep springs.
HANDOVER.LIFT_MS(420ms) is a real gap between two RECORDS. It is the price of the arm going back to the start rather than teleporting, and of the record on the deck being seen to change. Skipped entirely when the animation is off or underprefers-reduced-motion— and it does not apply within an album, where the tracks overlap instead.- Two tracks of the same record genuinely crossfade (2026-09-09). They
overlap on two
<audio>elements over 1.8s at a natural end, 0.9s when you press Next, and the needle goes back to the start while they cross. This used to be impossible and the README said so; what changed is that there are two decks now, not one. - A different record does NOT blend. It fades out, the record lifts off and
fades away, the new one fades in, and the needle resets — which is what
HANDOVER.LIFT_MS's 420ms of silence is for. Which of the two you get is decided inlib/transition.ts, from what changed and what the animation slider says.
The records waiting their turn are drawn beside the deck as a row of the
same medium — UpNextReel, one item per QUEUE ENTRY rather than per album,
because what goes on the player is a track. Each one is named underneath and
each one is a button that plays that track, so reaching track six no longer
means pressing next five times. As each one is loaded it shrinks out of the row
and the rest slide along to fill the gap; only as many as fit on one line are
shown, measured from the row with a ResizeObserver rather than guessed from
the viewport.
aria-hidden and is not any more. That was right while it
was a picture of a queue that had a real list underneath it; now that each item
carries a name and plays its track, an aria-hidden button would be unreachable
by keyboard while still taking up the space. The row is exposed and the
drawings inside it stay hidden. What survives of the old argument is that this
is the short version — as many as fit on one line, no remove button, no scroll.
"Up next" underneath is still the complete list and still where the queue is
edited.
--jb-item, set by the component. It was a hard-coded 100px in
@keyframes jb-reel-out, which only worked because the element also sets
width — widen the column past that number and the record hangs still for the
first part of the animation and then jumps, which reads as a dropped frame
rather than as a wrong constant.
Pause freezes all of it where it stands: the platter's animation-play-state
is paused rather than the animation being removed (removing it snaps the record
back to 0°), and the needle's angle comes from currentSec, so it simply stays.
Every other way of starting audio goes to the deck. The exception is the small headphones button beside each track in the library and on every album: ten seconds, taken ten seconds in — the first ten seconds of a track are the part least like it — with no queue, no change of screen, and whatever you had on still cued up behind it.
<audio> element, which is a deliberate exception to
the one-element rule below. One extra element, reused for every preview and
emptied the moment one stops, pins nothing between previews; what it buys is the
queue surviving a listen. It does pause the music first — two records at once is
not a preview.
#/tidy, from the app menu. It looks for two things and proposes them:
- Missing artwork that is already on your disk — a
cover.jpg(orfolder,front,albumart…) sitting beside the tracks, or art embedded in a later track of the album. The scan only asks the first track it meets for art, so an album whose sleeve is on track 2 shows nothing at all. - Records split in two by inconsistent tags — two albums with the same name in the same folder under different artist spellings, and tracks with no album tag sitting in an album's folder.
Every other player fixes missing artwork by asking MusicBrainz or the Cover Art Archive, which means sending someone's album and artist names to a server. This app's whole claim is that nothing leaves the machine, and "we only send the metadata" is exactly the sentence people say when they have quietly started sending something. If the art is not on the disk, the app says so.
⚠️ This heading used to say "there is no lookup", and the lyrics panel broke it. Read Lyrics before deciding this section is merely out of date. The paragraph above was written as a warning about a sentence, and the Settings page now says almost that exact sentence — so the difference has to be argued rather than assumed, and it is argued there. What has not changed: the tidy-up still asks nothing of anybody, artwork is still never looked up, and nothing here is a precedent for the next feature that would find a server convenient.
keys.ts puts it plainly: a wrongly merged album cannot be told apart
afterwards. So tidy.ts finds candidates, explains each in a sentence, shows
the actual picture it would use — and a person presses the button. The rules are
deliberately narrow, and tidy.test.ts spends more of its length on what they
must refuse than on what they find:
| Refused | Why |
|---|---|
| Same album title in different folders | "Greatest Hits" by two artists, or one record owned twice |
| Two different albums sharing a folder | A folder of singles — merging destroys two records to make one that never existed |
Album vs Album (Deluxe Edition) |
Different releases with different track lists; somebody with both has both on purpose |
| Loose tracks where the folder holds two albums | No single right answer, so it says nothing |
| Any image that is not named like a cover | IMG_4821.jpg is a photo. A wrong picture is worse than an honest blank tile |
| Anything fuzzy-matched | Where a tidy-up feature starts destroying libraries |
Tidying corrects the library here; it does not rewrite tags or move files. The fixes live in their own IndexedDB store keyed by album id and track id — both derived from the files themselves — so a rescan re-applies them rather than undoing them. That is the whole reason they are stored apart from the library they correct: a tidy-up you have to redo after every new album is worse than none, because you have to remember whether you did it.
The Lyrics button on Now Playing. The words come from one of two places, and the order is the whole design:
- The file's own tags, always first. ID3
USLT(andTXXX:LYRICS, which is where some taggers put it instead), the VorbisLYRICS/UNSYNCEDLYRICScomment, and MP4's©lyr. This costs one range read, works with the network unplugged, and is right even when it disagrees with the internet, because it is the user's own data. - lrclib.net, only if you turn it on. Off by default. See below.
Where the sheet is LRC — a [mm:ss.xx] at the head of each line — the panel
follows the music, and clicking a line seeks to it. Most tagged sheets are plain
text and show as plain text; nothing pretends to follow a sheet that has no
times in it.
readTags will not give you lyrics unless you ask (wantLyrics), the same way
it will not give you a cover. A sheet is a few kilobytes; carried on every
Track in a 5,000-file library that is ~15 MB of strings held in the store and
written to IndexedDB, for text that is only ever looked at one track at a time.
Instead the file for the track on the deck is re-read when the panel opens.
There is a check in scripts/selftest.mjs that fails if lyrics ever start
arriving by default — it is guarding the memory decision, not the parser.
This is one of the two parts of the app that touch the network — About this track is the other, and keeps the same four rules — and it contradicts a heading three sections up. It is worth being exact about what makes it different, because "it's fine, it's opt-in" is not by itself an argument:
- It is off until somebody turns it on, having read one sentence saying what will be sent. The artwork lookup this app refuses is the kind that is simply on.
- The browser talks to lrclib.net directly. There is no key to hide, so there is no reason for a Worker of ours in the middle — and if there were one, UNI·SIM would hold a log of what everybody listens to, which is worse than the thing being avoided. This is also why LRCLIB rather than Musixmatch: a licensed API needs a secret, a secret needs a server, and the server is the part that cannot be made private.
- Artist, title, album and length. Nothing else — not the path, not the library, not an id for the person or the device.
- Once per track, ever. The answer, including "nobody has this one", goes in
the
lyricsIndexedDB store. Settings shows how many tracks have been looked up and has one button that forgets the lot.
On the licensing. LRCLIB is free, key-less and community-contributed, and it is what open-source players use because every licensed alternative (Musixmatch, LyricFind) is a commercial contract — Genius's API does not serve lyric text at all. It is not a licensed source. James took that call knowingly on 2026-09-09; this paragraph exists so that whoever reads it next knows it was a decision and not an oversight.
All four are covered by lyrics.test.ts, and none of them look like a bug on
screen — they look like somebody else's badly made lyric file:
| Trap | What it looks like |
|---|---|
A line stamped twice ([00:09][02:17]) read once |
The chorus never comes back; the sheet is stuck three minutes from the end |
[offset:+500] applied with the wrong sign |
The sheet is a second out instead of correct — the correction doubles the error |
A plain sheet with one stray [00:00.00] treated as timed |
One line highlighted for the whole song |
| A half-timed sheet shown as synced | Every untimed line silently missing |
A fifth lives in tags.ts: LYRICIST is a person's name, one letter from
LYRICS, and reading it would put a songwriter's name on screen where a song
should be. The FLAC and v2.4 fixtures both carry one, next to the real field.
The bottom of the Jukebox tab is a tinted panel with a steel rail, a shelf of requests: a reminder of a song, an album or an artist you mean to get. Each stands as a record, in a picture you can pick for it, and is ticked off when it turns up in your library.
- Ticked by the library. Every time the library settles (opened, rescanned, a folder added), each open request is looked for in it. Titles and names are compared folded: case, accents, punctuation, a bracketed tail like "(2019 Mix)", a leading "The" and "&"/"and" don't matter. A song or album with an artist given has to match that artist too. You can tick or untick one by hand. One you untick stays unticked, so a wrong match can't keep ticking itself again. A ticked request is shaded out.
- Drawn as what it is. An artist is a fan of sleeves (their picture in front of two plain ones), an album is one square sleeve, a song is a 45.
- Song, Album or Artist. A new request starts on Artist. Double-tap one of the three to start there every time, as with the library tabs.
- Find a picture sends what you typed to
itunes.apple.com/search, straight from the device. It needs no key and there is no server of ours in between.⚠️ Apple answers an iPhone's browser with a redirect tomusics://(the Music app), which no page can follow. So the iOS app asks through Capacitor's native HTTP instead, and Safari on an iPhone says it can't search there. The picture you pick is downloaded once, shrunk to 240px and kept with the request, so drawing the shelf never goes online. iTunes has no photos of artists, so an artist request offers their album covers instead. - Kept in
localStorage(jukebox:requests), up to 200. When the list is full, the oldest ticked requests go first.
Sleep, in the row of round buttons on Now Playing: a tap steps 15, 30, 45,
60 minutes, then off; a hold opens a picker for any time up to 12 hours. The
music fades over the last minute, then pauses (lib/sleep.ts,
stores/sleepStore.ts). The fade is a factor of its own in lib/audio.ts, so
it never touches the volume slider or a crossfade. timeupdate as well as on a timer, because a locked phone suspends
the timer and a sleep timer is used with the screen off.
An i (About) in the row of round buttons under the records waiting to go
on (on a phone that row is folded away: the page stops at the records, and one
more swipe up pulls it into view — components/FoldUp.tsx, the search box's
pull in reverse), beside shuffle, the repeats and Add to shelf. Tapped, it scrolls the page
so the i sits at the top of the screen, under the navbar, with the panel
below it; closing it scrolls back up to the record. (2026-09-13: it began as a
labelled button beside Show lyrics, then sat under the song's title.) It
opens what Wikipedia says
about the song — who wrote it, whose it was first, how it charted — and about
the artist, with their picture. The asking is src/lib/aboutTrack.ts, when it
may ask is stores/aboutStore.ts, and the panel is components/AboutTrack.tsx.
It keeps the lyrics lookup's four rules:
- Off until it is turned on, either in Settings or with Look it up on Wikipedia in the panel itself, beside the sentence saying what it sends.
- The browser asks en.wikipedia.org directly, with no referrer.
- Only the song's title and the artist's name.
- Asked once. Each answer, "nothing found" included, goes in the
aboutIndexedDB store: 90 days for an answer, 3 for a miss. Settings counts them and can forget them.
How it finds the right article:
-
The song: a Wikipedia search for
"title" artist song. A result counts only if all of these hold:- its title, less any "(…)", is the song's;
- its "(…)" says song or single, or names the artist without naming an album, EP, film and so on;
- its opening calls itself a song;
- it mentions this artist.
That last rule means a cover is found under its original artist. "Hurt" by Johnny Cash gets the Nine Inch Nails article, whose opening says whose song it is. It also means a song nobody has written about gets nothing, rather than the famous song of the same name.
-
The artist: their name as a title, then
(band),(musician),(singer),(rapper)and(group). The page it lands on must still carry that name, because "Prince (band)" redirects to The Revolution, and it must be about a musician. Failing that, a title search. -
⚠️ MusicBrainz was tried and left out (2026-09-13). Half its answers were 503 "busy", and itscoverflag was empty on two famous covers. Its re-releases also made "first released" read 2023 for a 1963 recording. A second service that learns what someone listens to has to earn its place.
Wikipedia's text is CC BY-SA 4.0, and the panel says so, with a link to each article.
#/settings, reachable from the app menu. Everything is per-device and written
straight through to localStorage on change; there is no Save button because
nothing here is a form.
| Setting | Notes |
|---|---|
| Open my library on | Artists · Albums · Tracks · Jukebox. Also set by double-tapping a tab |
| Deck | Vinyl · CD · Cassette · Jukebox · Random. Changes the picture on Now Playing and the start-up sound, never the music — see below |
| Record-changing animation | A slider along a frequency ladder: Every track (default) · When the album changes · When the artist changes · Once per visit · Never. Governs the ceremony, the change-over animation and how often the start-up sound plays |
| Needle-drop sound | The thunk and surface noise — on a new record, between tracks, and on a preview. Its level is a ±5 slider, 0 being the level it has always been |
| Volume boost | 1–4× on top of the volume slider, for quietly-mastered albums |
| Fade in / Fade out | 0–8s, at the ends of a track. Separate from the crossfade between two tracks of one album — see below |
| Colour scheme | Follow global (the default: whatever Global preferences says, itself Light until changed) · Light · Dark · System. The same choice as App preferences in the menu |
| Notify me of each new song | Off by default. One notification per song — title, artist, cover — each one replacing the last, never a stack, and silent. Turning it on is what asks for permission. See below |
Two rows do something rather than set it: Tidy up library (under Your library) and Show the tips again (under Appearance, beside the colour scheme). Both moved out of the Actions menu on 2026-09-11, which now holds only Your complete library (opening to its folders and what you can do to them), Settings and About.
Adding one should be a field and a default in stores/settingsStore.ts plus
one <Choice> / <Slider> / <Toggle> in components/Settings.tsx. The page
is a list of sections of rows precisely so that stays true.
Every section starts shut, and a shut section says what it is set to —
Sound: Boost off, 1.5s fades — through its summary. That line is written by
hand per section but built from the store with the same labels and formatters
the controls display, so it cannot say one thing while the slider says another.
A new setting that matters at a glance belongs in its section's summary too.
The folds deliberately do not remember which were open.
Off until it is turned on under Settings → Notifications. Turning it on is
what asks the system for permission, and the setting is stored only once the
answer is yes. Each song that goes on then gets one notification — its title,
artist and album, and the cover — and the next song's replaces it rather
than joining a stack. None of them makes a sound. None is shown while
Jukebox is on screen (James, 2026-09-13): the song is in front of you then,
so the last song's card is taken away instead. In the phone apps "on screen"
means the page is visible; in a browser or the Windows app the window must
also have focus. Previews don't notify, and
neither does choosing a different deck mid-song or repeat-one bringing the same
song round again. The code is src/lib/trackNotify.ts.
| Where | Shown by | Replaced by |
|---|---|---|
| A browser, the Windows app | new Notification (Electron turns it into a Windows toast) |
closing the last one, then showing a fresh, silent one |
| Chrome on Android, an iPhone Home Screen web app | the service worker's showNotification — these refuse the constructor. A tap brings the player forward (public/notification-click.js) |
closing every card the worker is showing first |
| The iPhone app | ios/App/App/NotifyPlugin.swift — a banner even while the app is open |
one fixed identifier |
| The Android app | NotifyPlugin.java, on its own Now playing channel: high importance, no sound, no vibration |
one fixed id |
tag looks like the obvious route on the web, and isn't. A
same-tag replacement is silent unless renotify is set, so the card changes in
the notification centre without ever appearing — and renotify can't be
combined with silent, so setting it would also ping over the music.
Safari in an iPhone tab has no Notification API at all; there the switch is disabled, with a line saying to add Jukebox to the Home Screen first.
Vinyl · CD · Cassette · Jukebox. One timeline, four skins: the ceremony's
beats, the progress driving them and every rule in lib/ceremony.ts are
identical for all four. The temptation is to let each medium have its own timing
"because a CD is quicker" — don't; the store's asserted beats and
ceremony.test.ts cover ONE timeline.
Adding a fifth is a row in lib/decks.ts (the words), a face in
components/decks/ (the picture), a shape in decks/face.ts (the frame and
where the notes drift from), a cue in lib/crackle.ts (the sound) and a value in
the DeckStyle union. Every one of those tables is keyed through the union, so
leaving any of them out fails the build rather than shipping a blank deck.
DeckStyle and DeckSetting are two types on purpose. DeckStyle is a
machine — always one of the four. DeckSetting is what the user picked, which
has one more value: random. FACES, SHAPES and CUES are all keyed on the
narrower type, so nothing that has to draw or sound a deck can be handed
random — it has to go through resolveDeck() first. The version of this that
made random a DeckStyle compiled fine and rendered nothing.
Random is a rotation, not a dice roll — vinyl → CD → cassette → jukebox →
round again, indexed by the track's position in order. A real random pick
repeats (three cassettes in a row is an ordinary outcome) and the complaint that
produces is "the random setting is broken", which it would not be. Being a pure
function of a position is also what lets the row of records waiting to go on
show the machine each one is headed for, and what makes it agree with the deck
after a reload: nothing is remembered, so nothing can disagree.
The two lit pilaster tubes down the sides of the jukebox move to the music —
bass in the left one, mids and top in the right — driven by lib/useLevels.ts
off the same AnalyserNode the visualiser uses. Three things about it are
load-bearing.
It writes to the DOM, not to React. The hook sets a --jb-level custom
property on the elements it is given, once a frame. Returning a per-frame value
as state would re-render the cabinet, the record, the arm and the whole SVG
sixty times a second to move two coloured bars. Same argument as
Visualiser.tsx, which owns a canvas for the same reason.
The property is REMOVED when nothing is metering, never set to zero. Each
tube is two elements — the glass, which is always there, and the light inside it
— and the light's height reads var(--jb-level, 1). So a paused deck, a browser
that will not give an analyser, and prefers-reduced-motion all fall back to the
solid lit bar the tubes have always had. Zero would make all three of those
states a dark tube, i.e. a machine that looks switched off.
fftSize: 128 each is about
345 Hz — so splitting the range in half puts everything a listener would call
bass, and most of what they would call the tune, in the first band, and hands
the second one 7 kHz upwards, where music is nearly silent. Split that way the
upper tube sits on its floor through whole tracks.
Also asymmetric on purpose: a rise is instant and only the fall is rate-limited
(FALL_PER_FRAME). That is what makes a meter read as a meter — it snaps to a
beat and sinks back between them — rather than as a wobble.
overflow-hidden clips at the padding box — so with no padding
the clip line sat exactly on the record's top edge and took a slice off it. The
row carries pt-1 with -mt-1 to stay put. overflow-x-hidden is not the
alternative: setting one axis computes the other to auto, which clips anyway.
The fades need no Web Audio, and the boost cannot avoid it. An
HTMLMediaElement's volume is hard-capped at 1.0 by the spec, so gain above
unity has to go through a GainNode — which means routing the element through
an AudioContext, and that is a one-way door whose failure mode is silence
(createMediaElementSource may be called once per element, ever, and once
called the element's audio no longer reaches the speakers by itself). So the
graph in lib/audioGraph.ts is built only when something actually asks:
a boost above 1×, or the visualiser. Someone who never touches the boost never
takes that risk. ensureRunning() is called on every play because a suspended
context is silence, not an error.
The user's volume and the fade envelope are separate values, multiplied to
give element.volume. The obvious implementation — a fade writing straight to
element.volume — has no memory of what the slider said, so a fade-out ends
with the slider's own value redefined as zero and the next track silent.
There are two elements, and exactly two. A crossfade needs two files
decoding at once, so lib/audio.ts keeps a deck A and a deck B, swaps which one
is "active" at every blend, and revokes the retiring one's object URL when its
ramp finishes — at most two files pinned however long the queue runs. Three
things follow that are easy to get wrong:
- Every element event is gated on being the active deck. A retiring deck is
still playing and still firing
timeupdateandended; ungated, the scrub bar jumps between two tracks and the queue advances twice. - The graph captures BOTH elements in one go.
createMediaElementSourceis once-per-element and permanent, so a graph built over only the active deck would silence the app the first time the other one took over. - The two halves of a crossfade are a staggered pair, not linear. Two
linear ramps crossing dip audibly in the middle, which is the seam the
crossfade exists to hide. The pair was equal power (sin rising, cos falling)
until 2026-09-13, when James found two songs at 71% together harsh: "first
track goes more quiet until the second track kicks in". Now the arriving
song is silent for the first quarter of the blend, the leaving one is gone by
three quarters, and half-way each is at half (
fadeCurve.ts). A next song with a quiet start is still covered by the intro hold, which keeps the song ending at full until the new one's music begins.
Fade in / fade out are still linear, because a fade to or from silence has nothing on the other side of it.
Free and open source, like every Universal App. Part of the UNI·SIM suite.
AGPL-3.0-or-later, with an added permission for app-store distribution. Use it, change it, share it — and if you run a changed copy and let other people use it over a network, offer them your source.