android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,35 @@
|
||||
android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
|
||||
|
||||
The desktop's two playlist pages on the phone: the shelf (pinned first,
|
||||
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
|
||||
chip, description, the meta line's warnings, Play / Shuffle / more, a
|
||||
filter, rows tagged unresolved / set audio / file missing). Sheets for a
|
||||
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
|
||||
Add to playlist on every track and record sheet; a removal undoable for
|
||||
seven seconds from the neutral toast (notice.svelte.js); stream rows play
|
||||
through the resolver as path-less entries, a set slice starts and ends at
|
||||
its cues; Add to library / Download all tracks with the desktop's bulk
|
||||
rules; nav.replace for a review that became the thing it made.
|
||||
|
||||
The import doors that need no file — a link (YouTube · Spotify · Apple
|
||||
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
|
||||
review screen and the desktop's catalog verification pass. A share plugin
|
||||
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
|
||||
the text in a ShareActivity and the WebView hands it to the import when it
|
||||
comes back to the front. You → Sources: the source-quality window and the
|
||||
priority per mode.
|
||||
|
||||
Found by the walks and fixed: set_settings takes camelCase arguments, so
|
||||
the phone's normalization and library-folder saves never landed;
|
||||
Cover.svelte's non-eager wrapper has no layout box, so its intersection
|
||||
observer never fired and row thumbnails never loaded (the Artists tab too);
|
||||
a history push while a history.go was still in flight left the nav mirror
|
||||
one entry ahead and the page blank after a confirmed delete — the mirror
|
||||
now applies once per microtask and never pushes during a pending traversal.
|
||||
|
||||
The harness names unmocked commands, carries console warnings' frames (the
|
||||
smoke bundle is built unminified), probes the history mirror on every step,
|
||||
mocks app_status / play_local / waveform / lyrics_get / player_state and
|
||||
applies settings patches, runs adb steps on the phone (the share intent),
|
||||
and walks playlists, the imports, the share and the settings on both the
|
||||
mock and the phone. ANDROID.md documents all of it.
|
||||
@@ -0,0 +1,8 @@
|
||||
android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
|
||||
|
||||
Without a drag: a playlist row's sheet moves it up, down or to the top and
|
||||
persists the whole order in one call (playlist_set_order, as the desktop's
|
||||
drop does); a queue row's sheet moves it up or down, never past the track
|
||||
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
|
||||
walk, the embedded build installed, the phone walk, the commit, each step
|
||||
gated on the one before.
|
||||
@@ -0,0 +1,5 @@
|
||||
android: the room list filtered by name, the rest on demand
|
||||
|
||||
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way
|
||||
to the rest. Community → Rooms now has a filter by name and extends the
|
||||
list 200 at a time.
|
||||
@@ -0,0 +1,8 @@
|
||||
android: a playlist's cover from a photo on the phone
|
||||
|
||||
The storage plugin gains pick_image — the document picker limited to
|
||||
images, the picked content copied into the app's cache so the Rust side
|
||||
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
|
||||
install step split out and shared (squared, the master cover, the row's
|
||||
revision moved). A playlist's sheet offers Choose cover photo and Remove
|
||||
custom cover; the mock walk exercises both.
|
||||
@@ -0,0 +1,6 @@
|
||||
android: hours on the library's count line, By format, Jump back in on the empty search
|
||||
|
||||
The desktop Library summary's hours on the phone's count line; a By format
|
||||
order from the file stats (lossless by depth and rate first, then by
|
||||
bitrate, the badge as the card's line); the empty Search page's Jump back
|
||||
in — the last records played — as the desktop's empty search shows them.
|
||||
@@ -0,0 +1,5 @@
|
||||
android: the nav's badges — transfers in flight on You, unread chats on Community
|
||||
|
||||
The desktop's title-bar download ring and its unread indicator, on the
|
||||
phone's nav: a count on You while Save is fetching, a count on Community
|
||||
when a chat has unread lines.
|
||||
@@ -0,0 +1,6 @@
|
||||
android: the hub's filters on the network results, pasted lyrics
|
||||
|
||||
The raw results take the Soulseek hub's filters — a free slot only, FLAC
|
||||
or MP3 320+ — as chips above the folders. The lyrics sheet has the
|
||||
desktop's paste door: plain text, or LRC with timestamps, kept for the
|
||||
track.
|
||||
@@ -0,0 +1,7 @@
|
||||
android: downloads on Wi-Fi only
|
||||
|
||||
One switch the desktop never needed, off by default: on mobile data every
|
||||
Save the phone can start — a record, a track, a folder, a file, a
|
||||
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
|
||||
streaming is never held back. The walk flips the switch, makes the
|
||||
connection cellular and sees a download refused.
|
||||
@@ -0,0 +1,30 @@
|
||||
android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
|
||||
|
||||
The playlist repair (Find a local match: the library searched by the
|
||||
row's name, a pick relinks it through playlist_relink_item; an unknown
|
||||
row takes the track's identity). A peer's sheet on the raw results (play
|
||||
or save the folder, browse their files, message, buddy) and the Browse
|
||||
screen — the hub's Browse tab: a user's whole share with sizes and totals,
|
||||
a folder played or saved whole; a share can hold thousands of folders, so
|
||||
it filters and renders 80 at a time. "Look up a user" on Community →
|
||||
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
|
||||
next, Remove from queue), a catalog song's, a catalog card's and the
|
||||
record page's own, a raw file's (in the results and in Browse), a room
|
||||
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
|
||||
streamed track or record). The artwork viewer with pinch zoom, one layer
|
||||
the back gesture peels. Search's recent searches and its spelling
|
||||
suggestion from the names this phone knows. You → Streaming, Downloads,
|
||||
Sharing, Lyrics, Album artwork, and Clear play history; a record's
|
||||
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
|
||||
nudged and kept per track, another LRCLIB candidate chosen, the lookup
|
||||
forgotten and done again (lyrics.svelte.js, shared by the face and the
|
||||
lyrics sheet).
|
||||
|
||||
The harness: every CDP call times out and the reload step re-attaches
|
||||
(a WebView that stopped answering hung a walk for ten minutes); the
|
||||
audio check counts SoulStream's own tracks' underruns from the mixer dump
|
||||
instead of the thread-wide counters that any app's notification moves —
|
||||
the afternoon's "dropouts" were other apps' sounds, and the browse of a
|
||||
49,544-file share measured at zero; __settle resets every tab to its root
|
||||
through the nav; an adb step kind; the walks cover all of the above on
|
||||
the mock and on the phone.
|
||||
@@ -0,0 +1,11 @@
|
||||
android: a peer in full — their card, their interests, and what they have that you don't
|
||||
|
||||
The peer sheet's new "About this user" row opens a screen of its own: the
|
||||
card their client serves (their own words, their picture, the upload slot
|
||||
and the queue), the likes and hates they declared, and the comparison this
|
||||
phone makes of their share against the library. The comparison is the
|
||||
point of opening a stranger — every record they have by an artist in your
|
||||
library that you don't, each row opening the search that finds it, and a
|
||||
way to forget the whole comparison again. Nothing is uploaded to work it
|
||||
out. Their picture opens in the artwork viewer, which now takes a data:
|
||||
key beside a library path and an https URL.
|
||||
@@ -0,0 +1,12 @@
|
||||
android: a name of your own for anyone on the network
|
||||
|
||||
The desktop's local contact metadata on the phone: a peer's sheet ends in
|
||||
Name and notes — a petname, a group, a private note and the Favorite and
|
||||
Trusted marks, kept in the app's own config and published nowhere. The
|
||||
petname stands in for the username on the buddy list, in the chat list, on
|
||||
a thread's bar, in the peer sheet, in a room's roster and on a room line,
|
||||
and the real username stays with it — on the line beneath, or beside the
|
||||
name in a room — so a stranger is never quietly turned into someone you
|
||||
know. Favourites sit at the top of the buddy list, and clearing a person's
|
||||
details drops the entry rather than storing an empty one — the backend's
|
||||
own rule.
|
||||
@@ -0,0 +1,11 @@
|
||||
android: your own Soulseek interests, and who the server says is like you
|
||||
|
||||
Community's fourth tab is Taste: the likes and hates this client
|
||||
publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set),
|
||||
added and taken back as chips, and the people the server ranks as close to
|
||||
them (slsk_similar_users) — a row each into the peer sheet the app already
|
||||
has. Every declaration asks the server again and the chips stay quiet
|
||||
until it has answered, the way the desktop does, because the ranking only
|
||||
moves when its input does. The lists live in taste.svelte.js, read once
|
||||
per account when the tab is opened; the mock walk declares a like, watches
|
||||
the match grow, declares a hate and takes the like back.
|
||||
@@ -0,0 +1,12 @@
|
||||
android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
|
||||
|
||||
The editor the desktop drives with a mouse is better under a thumb: the
|
||||
song plays, the line to time sits large in the middle of the screen with
|
||||
the one behind and the one ahead faint around it, and a tap anywhere
|
||||
stamps it. One layer the back gesture peels, and a set left mid-tap is
|
||||
offered back by the row that opened it; Save writes the track's own
|
||||
document, lines never tapped left out of it and the button saying how
|
||||
many are kept. The lyrics sheet also gains the per-track auto-sync switch
|
||||
and the desktop's reduced-vocals copy — the same title, artist and
|
||||
duration, so the words keep following it, and one row puts the original
|
||||
back.
|
||||
@@ -0,0 +1,11 @@
|
||||
android: a folder of audio files, and a paste of track links
|
||||
|
||||
The paste door now reads itself the way the desktop's does: one track link
|
||||
in the paste and every line is looked up on its own public page
|
||||
(playlist_import_track_links), which is the way past Spotify's 100-track
|
||||
public ceiling — the wait screen counts them as they arrive. A fourth door
|
||||
imports a folder of audio files off this phone, picked with the storage
|
||||
plugin's own document-tree picker; playlist_import_folder grew a path
|
||||
argument for it, because on Android it had no dialog to open and always
|
||||
answered nothing. A YouTube playlist link needed no door at all — the link
|
||||
one already dispatches it.
|
||||
@@ -0,0 +1,13 @@
|
||||
android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo
|
||||
|
||||
Popular is asked on its own as well as with the page, so the section can
|
||||
paint before the full page is built and an artist whose page came back
|
||||
with none still gets one. A catalog record's rows that the album's own
|
||||
artist anchor found no count for — a feature's guest, a compilation's
|
||||
stranger — get the deferred Last.fm pass by their own artist id. The
|
||||
artist page's ⋯ is its own sheet now: choose a hero photo from this phone,
|
||||
or put the catalog's own back; set_artist_hero is pick_artist_hero's
|
||||
install step split out and shared, so the desktop's dialog keeps its door.
|
||||
You gains the desktop's Refresh artist portraits, and a data: URL is the
|
||||
image itself in the art gate — a chosen hero comes back from Rust as one,
|
||||
and the thumbnail fetch used to throw it away.
|
||||
@@ -0,0 +1,14 @@
|
||||
android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
|
||||
|
||||
The now-playing sheet's streamed track can be copied into a folder picked
|
||||
on the phone, as the file it is — the cache written out, nothing downloaded
|
||||
again, no library row (copy_stream_to_folder, which the desktop has had a
|
||||
command for and no UI). You → Sharing carries the listening port, what
|
||||
peers dial when they download from you: unset is the client's own 50300,
|
||||
the app asks the router to forward it over UPnP and falls back to the
|
||||
server relaying, and a change applies on the next connect. The dev flavour
|
||||
gets the desktop's debug overlay as a screen under This phone — CPU,
|
||||
memory, the network, the stream and its loudness, what the pre-buffer is
|
||||
fetching, the size of the play history — behind devBuild in util.js, which
|
||||
is Vite's own DEV flag, so the embedded build the phone keeps has no row
|
||||
to tap.
|
||||
@@ -0,0 +1,15 @@
|
||||
android: the phone turned sideways — the player beside itself, the chrome cut down
|
||||
|
||||
The phone UI was drawn portrait-first and had never been looked at in
|
||||
landscape. The player sheet was the casualty: its cover is a square sized by
|
||||
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
|
||||
transport and the face buttons all sat below the bottom of a sheet that does
|
||||
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
|
||||
is two columns now, the cover sized by height beside the controls, with a
|
||||
lyrics or queue face taking the cover's column; the nav and the mini bar come
|
||||
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
|
||||
always been rather than 300px of a 411px screen, the record head puts the
|
||||
cover beside the title, and the shell reads the side insets the cutout and the
|
||||
gesture pill take when the phone is on its side. The walk turns the phone over
|
||||
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
|
||||
assert what used to be off the screen.
|
||||
@@ -0,0 +1,17 @@
|
||||
android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
|
||||
|
||||
Every control on the phone has a name now, in the app's own words, and the
|
||||
desktop's word where it already has one; the nav is a tab set whose badge
|
||||
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
|
||||
read out before it. The two sheets are dialogs with somewhere sensible for
|
||||
the focus to land, and the half sheet's grab handle is finally a real
|
||||
Close — the first dismissal a screen reader could reach, because the scrim
|
||||
never was one. A sheet that is down is inert rather than sitting
|
||||
off-screen in the accessibility tree with the hidden mini bar and the nav
|
||||
under it, and one live region is mounted for the life of the app so that
|
||||
an undo offer is actually announced; switches say on or off, chips say
|
||||
pressed, and the waveform is a slider where it can seek. Nothing visible
|
||||
changed. The walk counts the controls with no name, with a junk name, in a
|
||||
hidden layer, or with unsaid state — all four held at zero on ten screens,
|
||||
and the nameless count on a room and on both prompts besides, which is
|
||||
where the six fields named here actually live.
|
||||
@@ -0,0 +1,14 @@
|
||||
android: the downloads say so in the shade
|
||||
|
||||
A Save still running with the app in the background now posts a progress
|
||||
notice — how many files, the one it is on, a bar, Stop all and Open — and
|
||||
takes it down when the queue empties. It sits on its own low-importance
|
||||
channel so it can be silenced without silencing the player's card, and
|
||||
rides its own dataSync foreground service rather than the media one: a
|
||||
mediaPlayback service stops itself when the bar empties, a download is not
|
||||
playback, and nothing else was keeping the process alive for a Save with
|
||||
nothing playing — which is how the first real Save died, four tracks in.
|
||||
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
|
||||
grew the matching button beside Clear finished. The walk pushes two rows
|
||||
straight into the transfer store and reads the real notification back out
|
||||
of `dumpsys notification`, without fetching anything.
|
||||
@@ -0,0 +1,8 @@
|
||||
android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
|
||||
|
||||
The desktop Library's per-record file stats on the phone (library_album_stats,
|
||||
one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards,
|
||||
the size on the list rows and the record page. The artist page's own
|
||||
listening (artist_play_stats): the time played as a chip, the most played
|
||||
track under "On this phone". The whole-library thumbnail warm a moment after
|
||||
boot, at the cards' edge, one decode at a time behind the tiles' gate.
|
||||
@@ -0,0 +1,19 @@
|
||||
android: the room can measure the song, and a cold artist page stops waiting on one call
|
||||
|
||||
The tap-to-sync room draws the recording's own vocal band
|
||||
(lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the
|
||||
head, and one button lays every line across the stretches it says are sung —
|
||||
the desktop's own generator, shared and unit-tested, which refuses rather
|
||||
than guesses when there is too little voice to read. The result is labelled
|
||||
an estimate everywhere it shows, the cursor goes back to the top so the taps
|
||||
correct it, and one more tap drops the whole thing back to an empty set,
|
||||
because a rough fill stamps every line at once and leaves Undo nothing to
|
||||
take back. The artist page now fires the hero seed and the overview beside
|
||||
the full page, the way the desktop stages them: a hero Kyle picked on this
|
||||
phone comes back off the file with no network at all, the discography paints
|
||||
from the overview's first page until the exhaustive pass lands with the
|
||||
classified one, and both lanes are dropped with the page when a hero
|
||||
changes. ANDROID.md's "Not done yet" is rewritten as the true map of the
|
||||
port — what is absent by design and why, what is waiting on Rust rather than
|
||||
on a file picker, and one bullet about the artwork viewer that had been done
|
||||
for rounds.
|
||||
@@ -0,0 +1,29 @@
|
||||
android: what the phone says when things go wrong
|
||||
|
||||
Fifty-nine places put a raw Rust string into a field a person reads, and
|
||||
the red toast drew it verbatim — a phone in a lift showed "could not reach
|
||||
server: io: failed to lookup address information" under the password box.
|
||||
One translator, src/mobile/trouble.js, called where an error is DRAWN
|
||||
rather than where it is caught: the stores keep what Rust said and only
|
||||
the sentence on screen is ours, so every sink is fixed at once. Five
|
||||
screens that drew a failed read as an empty answer stop lying — Tracks no
|
||||
longer reports a broken database as an empty library, a playlist that
|
||||
could not be read is not an empty playlist, the lyrics face says a lookup
|
||||
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
|
||||
lyrics, Gaps says why it could not reach the catalogue, and Search signed
|
||||
out says so where it used to render nothing at all. The room list, which
|
||||
is fire-and-forget over the wire, gets a twelve-second end and a Try
|
||||
again.
|
||||
|
||||
rescan_library ran prune_missing() first, which deletes every track row
|
||||
whose file does not exist — so one tap on You → Rescan library with the
|
||||
folder unreachable emptied the whole index and returned Ok(()). It now
|
||||
makes the root if it is merely not there yet and refuses if it cannot be
|
||||
read, and You draws the path with what to do about it.
|
||||
|
||||
The mock grew a weather system so the walk can drive all of this: fail,
|
||||
answer, offline and clear, checked before dispatch so a plugin command
|
||||
arms like any other. Nine mock steps and one on the phone — the first
|
||||
steps in thirty rounds that ever made a command reject or saw the client
|
||||
signed out — and the last of them puts the weather away and leaves the app
|
||||
as it found it.
|
||||
@@ -0,0 +1,54 @@
|
||||
android: the boot chain goes out in one round, and a census that holds it there
|
||||
|
||||
Thirty rounds of onMount had never been read end to end, and read end to end
|
||||
it was a queue: two dozen commands with a critical path ten deep, because
|
||||
Tauri's `listen` is an invoke like any other. loadLibrary asked for five
|
||||
independent SQLite reads one behind the other, bindSlsk registered eight
|
||||
listeners one behind the other, and watchLibrary and the player's bindEvents
|
||||
did two each. None of them depended on anything before them. They go out in
|
||||
one round now — the same commands, critical path 10 to 3, and ten of them
|
||||
used to leave in the shell's own first turn where twenty-three do now — and
|
||||
in a rig that runs the real store modules with the bridge instrumented,
|
||||
lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead
|
||||
of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip
|
||||
swept rather than measured; the phone's own numbers are unproven until the
|
||||
walk prints bootMs and bootLibMs.
|
||||
|
||||
The behaviour is meant to be identical. loadLibrary's only movable part was
|
||||
lib.error, which has two writers and used to be decided by the order they were
|
||||
awaited in; the two messages are now carried out of the round and applied in
|
||||
that same order, so a failing library still shows the library's message. Every
|
||||
combination of which of the five reads fails, over six latency shapes, comes
|
||||
out identical to the old code. bindSlsk still does not return until every
|
||||
handler is up, so connectStored cannot let a fast connected state land with
|
||||
nobody listening, and bindEvents still hands back the transport's unlisten and
|
||||
still rejects only when the transport is the one that failed.
|
||||
|
||||
src/lib/boot.js is the project's first launch instrument — one counter every
|
||||
invoke and every listen passes, sealed at the first frame the shell paints and
|
||||
never re-opened, so a probe landing a second and a half later still reads the
|
||||
launch. perf.mjs measures frames; nothing measured the launch. The mock walk's
|
||||
Home step now holds a budget against it and both walks print bootCalls,
|
||||
bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no
|
||||
clock in it: a call that waits on the one before it can only issue its first
|
||||
in the shell's own turn, so a chain put back in a queue falls under the floor
|
||||
whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there
|
||||
at all, which is the census itself being broken rather than the app.
|
||||
|
||||
One import line goes with it: Sheet.svelte's `import * as api`, the only one
|
||||
of the ten dead imports this round turned up whose reason is the bundle at
|
||||
all. Rollup already drops an unreferenced NAMED import, so a dead name on an
|
||||
import line is hygiene rather than bytes; a namespace import is the one shape
|
||||
tree-shaking cannot see through, and this one was dead. It frees nothing on
|
||||
its own — twenty-nine other files in the phone's graph hold the same module
|
||||
the same way, so api.js is pinned into the chunk whatever Sheet.svelte does —
|
||||
but it is the only one of the ten that belongs to a round about weight. The
|
||||
nine that are drift go out with the sweep in the round after this one.
|
||||
|
||||
Two things were looked at and left alone, both written down in ANDROID.md
|
||||
rather than half-done. Home's per-artist catalog walk is the deepest chain in
|
||||
the app, and that serial walk is the Deezer and MusicBrainz pacing, not an
|
||||
oversight. And the follows really are read twice at launch, but the obvious
|
||||
guard does not work: Home's effect calls loadHome with force the first time
|
||||
they land, so the guard would never fire. One small local read, left for a
|
||||
round that can do it properly.
|
||||
@@ -0,0 +1,23 @@
|
||||
android: the sweep — what thirty rounds left behind, and the byte that hid a file from grep
|
||||
|
||||
Nothing here changes what the phone does, and the walks are untouched on
|
||||
purpose: they are the guard, not the subject. Out go nine imports no file
|
||||
ever names again, two things nothing in src, tests, crates or src-tauri
|
||||
has ever referenced — `forgetAccount` in the Soulseek store, whose Sign
|
||||
out row calls `disconnect`, and `isLossless` in util.js — and two CSS
|
||||
rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and
|
||||
`fmtCount` had each been written twice word for word, so both bodies move
|
||||
into util.js unchanged and four screens gain a name on an import line they
|
||||
already had. The byte is in player.svelte.js, where the play-history key
|
||||
joins artist and title on a NUL that was written into the source as the
|
||||
character itself rather than as the escape catalog.svelte.js already uses
|
||||
— that made the file non-text, so every grep of it since has answered
|
||||
"Binary file matches" and shown nothing. One line is added rather than
|
||||
removed: `.mini .mini-lane { width: auto }` reads exactly like the strays
|
||||
and is the only thing keeping the lane from coming out 32px wider than the
|
||||
strip it was cut for, so it now says so — and `.feat`, declared twice in
|
||||
shell.css with different values, is left alone, because untangling that
|
||||
moves pixels and this round may not. A tenth dead import is missing from
|
||||
that first list on purpose: Sheet.svelte's `import * as api` is a namespace
|
||||
import, the one shape Rollup cannot shake, so its reason was the bundle
|
||||
rather than drift and it went out with the round before.
|
||||
@@ -0,0 +1,31 @@
|
||||
android: the first run — a folder to keep music in, and an account to reach the network
|
||||
|
||||
Nobody had seen this app on a phone that has just installed it. On Android
|
||||
the library root is never missing — config.rs falls back to the app's own
|
||||
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
|
||||
a null check, and setup_done has never been written by the phone, so every
|
||||
phone in existence reads as unstamped, Kyle's included. The tour therefore
|
||||
opens on three clauses, not one: no flag, no saved account, and no records,
|
||||
read only after the library and the credentials have both answered. Two
|
||||
cards, skippable, and no password anywhere in it — the Soulseek step goes
|
||||
to the card on Community and stops.
|
||||
|
||||
Every screen that is empty because of those two things now says so with the
|
||||
row that fixes it: Home (whose copy predated the folder picker), Library →
|
||||
Albums, Library → Artists, which had no empty state at all, and Search,
|
||||
where a flat sentence about signing in became the row that does it. A row
|
||||
goes away because the config moved, so the two doors onto the same folder
|
||||
tell each other — You re-reads when the tour moves it, the tour's store is
|
||||
told when You's own picker does — and the all-files row re-reads the grant
|
||||
when the system screen hands the app back, the way You already did.
|
||||
|
||||
You → This phone opens the tour again as a preview that reads the config
|
||||
and writes nothing, which is the door the phone walk drives on Kyle's own
|
||||
device. The mock can answer as a fresh install now — an empty database, no
|
||||
account, no permission, the sandbox root — so the whole thing is walked end
|
||||
to end, and two commands that had no mock at all (player_control,
|
||||
open_external) got one on the way past. It is the mock walk's first reload,
|
||||
which turned up two things a reload does not clear by itself: the player
|
||||
writes the session back on pagehide, so the fresh block clears it from a
|
||||
pagehide listener of its own, and the viewport emulation lives on the
|
||||
DevTools session, so the harness hands it back after the re-attach.
|
||||
@@ -0,0 +1,20 @@
|
||||
android: a playlist, out of the phone
|
||||
|
||||
The desktop has had Export since playlists v2 and the phone had no door onto
|
||||
it for one honest reason: `tauri-plugin-dialog` does open Android's
|
||||
create-document sheet, but it answers with a `content://` URI and
|
||||
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
|
||||
storage plugin grew a `create_document` whose Kotlin writes the bytes through
|
||||
the ContentResolver — a plugin function called from Rust, not a command, so
|
||||
the file never passes through the WebView and nothing was added to the ACL —
|
||||
and `playlist_export` now builds the file above the platform fork with the
|
||||
same `playlist_formats` writers, so the phone rebuilds no format and the two
|
||||
exports cannot drift. The door is the playlist's own sheet, on the page and
|
||||
on the shelf card, with the desktop's five profiles in the desktop's order; a
|
||||
dismissed sheet says nothing, because nothing happened. The mock walk drives
|
||||
it three ways — a file written where the sheet pointed, a sheet dismissed, a
|
||||
refusal read back as a sentence — and the phone walk opens the real sheet and
|
||||
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
|
||||
nothing in this tree compiles it, though it is written against
|
||||
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
|
||||
against a guess at them.
|
||||
@@ -0,0 +1,32 @@
|
||||
android: a playlist file, picked
|
||||
|
||||
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
|
||||
the document sheet with the caller's own filter, answered with a path Rust
|
||||
can open. A document already on this phone hands its path over as it lies —
|
||||
the document sibling of the same volume:relative id the folder picker
|
||||
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
|
||||
nothing; one that has no path is copied into cacheDir/picked under its own
|
||||
name, and only as far as the caller allows, because a playlist is kilobytes
|
||||
and a DJ set is not. Those copies are swept at the start of every pick,
|
||||
which pick_image's never were.
|
||||
|
||||
The import sheet has its fifth door, on round 24's screen, wait card and
|
||||
review, and the note that apologised for its absence says the one true thing
|
||||
about it instead. `playlist_import_probe` took a path and a name the way
|
||||
`playlist_import_folder` took a path, and the name is the load-bearing half:
|
||||
the parser picks its format off the extension and titles the draft from the
|
||||
stem, so a cache copy under an invented name would have read every playlist
|
||||
as plain text. The mock walk drives the door and both of the picker's
|
||||
refusals through round 31's fail door; the phone walk opens the real sheet
|
||||
and dismisses it with BACK, reading nothing and writing nothing.
|
||||
|
||||
The brief asked for a second consumer — sending a file in a chat — and there
|
||||
is nothing behind it: `community_pick_file` is the persona layer's three
|
||||
native dialogs, `soulseek-client` has `say_room` and `message_user` and
|
||||
nothing that sends a file, and both frontends' thread composers are text
|
||||
only. Nothing was invented for it. The plugin command is general enough — a
|
||||
caller's own filter, a zero-copy path, a bounded copy — that the one reader
|
||||
still waiting, the local set listener the phone puts off by design, needs no
|
||||
further plugin change. Playlist export is not on that list and never was:
|
||||
writing a document out is the share plugin's errand, not a picker's. The
|
||||
Kotlin is unbuilt — nothing on this machine compiles it.
|
||||
@@ -0,0 +1,40 @@
|
||||
android: the rows go where you put them — a real drag on the playlist and the queue
|
||||
|
||||
The phone stood in for the desktop's drag with Move up / Move down / Move to
|
||||
the top on a row's sheet, which is fine but is not what a finger is for. Press
|
||||
and hold a row for 320ms now and it lifts, the rows below make room, and
|
||||
letting go lands it — on the playlist page and on the player sheet's queue,
|
||||
through one action in src/mobile/reorder.js. The Move rows stay: they are the
|
||||
accessible path round 28 named to a screen reader, and they are the only way
|
||||
to reorder while the filter is on. Release a press without having moved it and
|
||||
the row's sheet opens exactly as a long press always opened it, which is what
|
||||
keeps the queue's sheet reachable at all — a queue row has no ⋯, so
|
||||
`oncontextmenu` is its only door to Play now, Move to next and Remove from
|
||||
queue.
|
||||
|
||||
Most of the work is in not fighting the page. Nothing carries
|
||||
`touch-action: none`, because a flick that starts on a row is how the list is
|
||||
scrolled and `touch-action` is latched at touchstart anyway; the scroll is
|
||||
refused instead by a non-passive `touchmove` listener added at the lift, which
|
||||
works precisely because the finger has been still for 320ms and the browser
|
||||
has not started one. A press that moves more than 8px before then is a scroll
|
||||
and the gesture lets go of it. Click and contextmenu guards sit on the
|
||||
container in the capture phase while a lift is live, or a released drag would
|
||||
fire the row's own click and play the track. Rows are measured once at the
|
||||
lift in the scroller's content space, so the edge pull — within 64px of either
|
||||
edge — leaves the snapshot true, and each displaced row moves by the exact
|
||||
difference between its top and its neighbour's rather than by one assumed row
|
||||
height, because the queue face puts album separators between rows.
|
||||
|
||||
No new mutation and no new command: `moveItem` and `moveInQueue` each take a
|
||||
numeric target now as well as "up" / "down" / "top", so there is still exactly
|
||||
one call to `playlist_set_order` carrying the whole id order, and the existing
|
||||
queue guards were already the right guards. The drop is optimistic and snaps
|
||||
back with the error toast if the backend says no. Two refusals cover the
|
||||
middle of a gesture — the playlist drops nothing if a refetch has replaced its
|
||||
rows, and the queue drops nothing if `player.qi` has moved — and dragging is
|
||||
off entirely while the playlist's filter has anything in it, because a
|
||||
filtered list's visible order is not the stored order. Both walks drive it
|
||||
with real pointer events and assert the order out of the store: a drag, a drag
|
||||
back, a refusal under the filter, a press that still opens the sheet, and a
|
||||
drop the backend is armed to reject.
|
||||
@@ -0,0 +1,19 @@
|
||||
android: the whole discography, on a page of its own
|
||||
|
||||
The artist page already filed every release the desktop's way, but it only
|
||||
ever showed one category at a time and it showed all of it: no slice anywhere
|
||||
in that grid, so an artist with 140 releases drew 140 cards. There is a page
|
||||
now — All in front of the categories, a sort, a grouping by year, and the
|
||||
editions opened in place under their own record, which is the part a flat
|
||||
list loses — and the grid behind it stops at twelve with the way down under
|
||||
it. It is the same classifier twice over the same cached albums, which the
|
||||
desktop cannot do because its filing waits on a background description
|
||||
harvest; the three things its own page has and this one does not — a
|
||||
tracklist per record, an accent sampled per cover, the scroll-led banner —
|
||||
are 140 calls and 140 decodes over mobile data for rows that are one tap
|
||||
away, and ANDROID.md says so rather than leaving them looking forgotten.
|
||||
|
||||
The list caps at sixty with the rest a button away, before the grouping, so
|
||||
the last year on screen can be a part of itself; under a name order By year
|
||||
is not offered, because the years would repeat out of sequence. The mock
|
||||
gains a 140-record artist so both caps are walked rather than argued about.
|
||||
@@ -0,0 +1,21 @@
|
||||
android: the faces the walk had never seen — six failure states, read back in their own words
|
||||
|
||||
Round thirty-one gave the phone honest error states and shipped six of them
|
||||
unobserved, because neither `loadHome` nor `loadLyrics` was reachable from the
|
||||
`__ss` handle and both answer a second ask with nothing. The handle carries the
|
||||
two stores and their two loaders now, and a block of mock steps arms each
|
||||
failure with the mock's own weather, drives the face, and asserts the sentence
|
||||
a person reads as well as the absence of the Rust string behind it: Home's
|
||||
catalog line, the Gaps line and its second tap, a catalog record's stub, the
|
||||
artist page's two different answers, a playlist that would not read, the lyrics
|
||||
lookup that failed, a refused transfer, and the library toast's Try again,
|
||||
pressed for the first time.
|
||||
|
||||
It sits before the first-run block, which reloads and empties the mock's
|
||||
follows; every failure is disarmed by the step that proves the face heals, and
|
||||
a last step asserts nothing was left armed. The phone walk gets a safe step of
|
||||
its own — a terminal transfer row written into the store and taken away,
|
||||
nothing fetched and nothing reaching the shade. ANDROID.md now names what the
|
||||
walk still cannot see: three screens that draw a raw backend string, six error
|
||||
faces with no expectation, and the history mirror printed on every step and
|
||||
asserted on none.
|
||||
@@ -0,0 +1,29 @@
|
||||
android: what the device has actually been tested for — the map, and the steps it could close
|
||||
|
||||
Most of this port is proven against an object in memory and only some of it
|
||||
against the phone, and nobody had written down which was which. Writing it
|
||||
down found four phone steps — `library-ambience`, `queue`, `lyrics` and
|
||||
`community` — named and screenshotted with no entry in `PHONE_EXPECT` at all,
|
||||
and the driver reads a missing key as a pass, so all four had been green since
|
||||
the rounds that wrote them; they have assertions now, tolerant of Kyle's own
|
||||
library and of nothing else.
|
||||
|
||||
Thirty-three more steps move onto the device: everything that can be proven
|
||||
there without downloading, joining, sending, signing in or writing to his
|
||||
library. The library's shape and its sort (nav state, not config), a local
|
||||
track's sheet off a real path, Search's recents out of this WebView's own
|
||||
storage, a queue row's sheet opened and not tapped, the walk's own playlist
|
||||
with a row removed and put back by Undo — the first round trip that stash has
|
||||
made through real SQLite — the import review's Keep switch restored before the
|
||||
draft is created, the sleep timer set and put out by the next step, the
|
||||
stream-cache chip and the Wi-Fi-only guard read first and restored last, and a
|
||||
port the core has to refuse.
|
||||
|
||||
ANDROID.md carries the map beside Verifying: what the device walk proves (121
|
||||
named steps, up from 88, plus the seven things after the loop that no mock can
|
||||
do), what stays mock-only and why — needs `__ssMock`, needs a fresh install,
|
||||
needs a viewport the phone walk refuses to set, needs the dev flavour, or
|
||||
would download, join, or destroy his data — which six of the new steps are
|
||||
conditional on what his own library happens to hold, and what still needs a
|
||||
person. No product code in this round: four inserts in the phone half of
|
||||
smoke.mjs, one in the doc.
|
||||
@@ -0,0 +1,52 @@
|
||||
android: the long session — what the app is still holding after an hour
|
||||
|
||||
Every walk this project has ever run is minutes long, and nothing in the port
|
||||
had been looked at for a leak. Driving the real bundle lap after lap says two
|
||||
useful things. The first is that the obvious answer is wrong: the naive
|
||||
add-minus-remove listener counter climbs about twelve a lap, and every one of
|
||||
those is a `.screen` element that `{#key nav.seq}` threw away — count only
|
||||
what is still attached to window, document or a connected element and it is
|
||||
flat, lap for lap, and so are the intervals. The second is that exactly two
|
||||
things grow without a limit. `art.svelte.js` kept every cover it had ever
|
||||
fetched for the WebView's lifetime — one browse of a 114-cover library takes
|
||||
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
|
||||
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
|
||||
stranded a 360 px data URL under a key nothing could ever read again.
|
||||
|
||||
Both are bounded at the desktop's own numbers rather than at invented ones:
|
||||
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
|
||||
`state.svelte.js`, oldest first. Two rules the desktop does not need are
|
||||
added, because its callers ask through `getArt(path)` while the phone's
|
||||
templates index the map: the entry that just landed is never dropped, and
|
||||
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
|
||||
re-arms only on a path prop change, so an evicted cover that is still mounted
|
||||
would stay a disc for good. Built with the cap forced to 8 and scrolled
|
||||
through eighteen screens of a 140-card Library grid, the map sat at 61 —
|
||||
spared, every one of them, for being painted — and not one cover on screen
|
||||
lost its image; at the shipped 512 a whole library walk runs the trim zero
|
||||
times. The playlist covers need no policy at all: the new revision drops the
|
||||
old ones, which are unreachable by construction.
|
||||
|
||||
The verification is the round. `artTally()` on the handle reports the map and
|
||||
its side tables, which are module-private; seventeen probe fields carry that,
|
||||
the catalog's ten session maps, the queue, the transfers and the DOM on every
|
||||
step of both walks; and seven mock steps install a live listener and timer
|
||||
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
|
||||
which the first-run block's reloads would throw away — drive two warm laps, a
|
||||
baseline and nine more, assert the census, and take the registry back out so
|
||||
nothing appended after this block inherits a patched platform. Installed that
|
||||
late it can only see what is registered from that moment on, so its absolute
|
||||
counts are small and are not asserted as a census; what is asserted is the
|
||||
delta across the nine laps, and that it fires was proved by planting a
|
||||
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
|
||||
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
|
||||
and the intervals moved by 0, the art map grew 17 against a headroom of 80
|
||||
and the catalog 5 against 24, and the DOM ended smaller than it started.
|
||||
`tallyTimers` is a ceiling and not a delta, and says so: every track start
|
||||
schedules a 47-second recents refresh, so a walk this fast keeps a handful
|
||||
pending, and making it sharp would cost a fifty-second quiet period. The
|
||||
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
|
||||
as left alone on purpose — evicting a catalog entry is not safe while
|
||||
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
|
||||
the registry is never installed there, those three fields read −1, and
|
||||
`artTally()` is a pure read.
|
||||
@@ -0,0 +1,44 @@
|
||||
android: turned over, for real — the walk rotates the phone, and puts it back
|
||||
|
||||
Round twenty-seven wrote the landscape layout and proved it at 915×411 in
|
||||
headless Chrome; no real device had ever been turned, and the phone walk
|
||||
refuses a `{ view: [...] }` step because a device's orientation is not the
|
||||
step list's to set. That refusal stays. The turn goes in the phone tail
|
||||
instead, right after `background` has proved playback is live with the app
|
||||
off screen, and it asserts the four things the mock physically cannot show:
|
||||
`screen.orientation.angle` is 90 or 270 — an override without
|
||||
`screenOrientation` leaves the angle at 0, so this one is only ever a real
|
||||
phone — the WebView RESIZED as well (a separate clause, because a WebView
|
||||
that never got the configuration change looks exactly like a display that
|
||||
never rotated), `--nav-h` computed to 48px so the phone-sized landscape query
|
||||
really fired, and the activity was not recreated: the same window token, the
|
||||
same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and
|
||||
those last two are read tolerantly, because `pidof` exits 1 when the process
|
||||
is GONE and `adb()` throws on a non-zero exit, so the plain read threw the
|
||||
whole report away at exactly the moment it mattered. Then the stack, the
|
||||
track still playing and past where it was (a track that rolled over during
|
||||
the turn is still playing, which is all the clause claims), nothing clipped,
|
||||
and the player sheet sideways in two columns with every control on screen.
|
||||
|
||||
The safe-area insets are printed, not asserted. Nobody had ever measured
|
||||
them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing
|
||||
in the generated Kotlin touches insets, so `env()` comes straight out of
|
||||
Chromium's window-inset mapping — and asserting a number nobody has measured
|
||||
is how a round fails on a correct build. What is asserted is the plumbing, on
|
||||
both edges, because a hole-punch is only on one of them: whatever `env()`
|
||||
says, the glass bar, the mini bar, the screen and the sheet spend it.
|
||||
|
||||
The phone is Kyle's, so the two rotation settings are read before either is
|
||||
written and put back from four places — the block's own finally, main's
|
||||
finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed
|
||||
clause only walks on but a WebView that stops answering throws clean out of
|
||||
the loop, and Ctrl-C runs no exit handler at all. The latch clears itself
|
||||
before it writes, a failed restore hands it back for the next caller, and the
|
||||
skip paths disarm rather than restore, so a phone that could not be read, one
|
||||
already sideways and `--no-rotate` all leave it untouched. Nothing else on the
|
||||
device is written.
|
||||
|
||||
One thing beyond the turn: the `{ adb }` step kept its stdout. `am start`
|
||||
prints "Error: Activity not started, unable to resolve Intent" on stdout and
|
||||
exits 0, so `share-import` — the step that exists to prove the share intent
|
||||
resolves — could not fail for its own reason. It can now.
|
||||
@@ -0,0 +1,35 @@
|
||||
android: the keep rules for a release build nobody has made
|
||||
|
||||
Every APK this port has produced is `assembleArm64Debug`, and the generated
|
||||
project turns R8 on for `release` and only for `release` — while tauri finds a
|
||||
plugin class by string, files every `@Command` under `commands[method.name]`,
|
||||
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
|
||||
exactly that, and it breaks it without a word: the app compiles, installs,
|
||||
launches, shows Home, and then a tap on Library folder answers "No command
|
||||
pickFolder found for plugin ..." in logcat and nothing on screen. So each
|
||||
plugin module now carries its own `consumer-rules.pro` naming its own
|
||||
reflected surface — the shape tauri-android and tauri-plugin-dialog already
|
||||
use in this build, and one that travels with the plugin into a regenerated
|
||||
`gen/android`, which a rule written into that gitignored tree would not.
|
||||
|
||||
What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
|
||||
copied into the generated project by the build script because the app's own
|
||||
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
|
||||
pair for the two `java.beans` classes jackson-databind references and Android
|
||||
does not have (R8 turns a missing class into a build error, so without them
|
||||
there may be no APK at all), and a keep for the four `@Command` methods
|
||||
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
|
||||
annotated `@TauriPlugin` and the members they declare; the base class carries
|
||||
no such annotation, so nothing names `registerListener` — which is what the
|
||||
lock-screen card's buttons and the download notice's Cancel and Open ride on.
|
||||
|
||||
`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
|
||||
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
|
||||
sign with the same debug key the phone already trusts, so the APK installs over
|
||||
the app rather than forcing an uninstall that would take Kyle's settings with
|
||||
it. With no argument the script is what it has always been, cargo line for
|
||||
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
|
||||
minutes. None of this has been built: Gradle, Kotlin and R8 do not run on this
|
||||
machine, the rules are a precaution against a silent failure rather than a fix
|
||||
for an observed one, and ANDROID.md says so and lists what to test by hand,
|
||||
command by command, on the first release build anyone makes.
|
||||
@@ -0,0 +1,32 @@
|
||||
android: the falcon on the launcher — a real adaptive icon, with the monochrome layer
|
||||
|
||||
The phone has been wearing tauri's logo. `tauri android init` writes the
|
||||
generated project's `res/` from its own template and never reads
|
||||
`src-tauri/icons/android/`, which has carried the falcon since 50afa6a, and the
|
||||
detour build runs cargo and Gradle directly — so nothing ever copied it across:
|
||||
the two dominant colours in the packaged `mipmap-xxxhdpi/ic_launcher.png` are
|
||||
`#24c8db` and `#ffc131`, tauri's own brand cyan and amber. `src-tauri/android/res/`
|
||||
is the app module's res now, tracked, and `build.sh` and `dev.sh` copy the whole
|
||||
tree into `gen/android` before Gradle, because that project is regenerated and
|
||||
gitignored and an asset that only lives there is an asset that disappears.
|
||||
|
||||
It is a real adaptive icon: the kit's foreground and its **monochrome** layer at
|
||||
five densities, the legacy mipmaps under it, and the background as one
|
||||
`#FF05080B` colour resource rather than five identical flat PNGs, so the gradient
|
||||
being mocked separately replaces one file. The kit's own XML could not ship — it
|
||||
points every layer at `@drawable/` while shipping them as PNGs under `mipmap-*`,
|
||||
and in the generated project two of those three references resolve to Android
|
||||
Studio's template robot rather than failing — so the overlay's XML says
|
||||
`@mipmap/` and `@color/`, and its colour file takes the same name tauri's does,
|
||||
because two files declaring one colour is a hard aapt2 error.
|
||||
|
||||
Losing the copy would be silent — the APK still builds, still installs, and wears
|
||||
the old logo with nothing in the log — so `verify-icon.sh` opens every APK both
|
||||
scripts make and reads the resources back out: the two `mipmap-anydpi-v26` XMLs
|
||||
present, and four PNG names at five densities byte for byte from the overlay. A
|
||||
release APK keeps neither the bytes nor the names — AGP re-encodes every PNG and
|
||||
shortens every resource path — so that path asks `aapt2 dump resources` what each
|
||||
name points at and compares pixel dimensions instead; 72×72 at hdpi still tells
|
||||
the kit's icon from tauri's 49×49. No walk step: a launcher icon is not reachable
|
||||
from inside the app, and the masking and the themed layer want a minute with the
|
||||
phone's home screen.
|
||||
@@ -0,0 +1,29 @@
|
||||
android: the icon where it moves — the glyph, the lock screen and a splash that is not white
|
||||
|
||||
Both notification glyphs are the same falcon now, the logo kit's monochrome
|
||||
path verbatim inside a group that re-frames it for a 24dp box — the playback
|
||||
card's and the transfers notice's, because a note beside an arrow does not
|
||||
read as one app, and the transfers one keeps the tray bar it always had so
|
||||
the falcon dives into it. The fill stays white on transparent: the shade
|
||||
composites a small icon from its alpha channel alone and tints it itself, so
|
||||
a coloured export would arrive as a white blob.
|
||||
|
||||
The lock screen falls back to the app's own icon instead of a grey hole.
|
||||
`MediaHub.art` is null from the moment a track changes until the frontend has
|
||||
finished decoding its cover, and null forever for a track with none, so
|
||||
`appIcon()` decodes the launcher icon ONCE — through the PackageManager,
|
||||
because a library module's R has no `ic_launcher` — and hands it to both the
|
||||
card and `METADATA_KEY_ALBUM_ART`. The app manifest lives in gitignored
|
||||
`gen/` and declares no category, so the media-session plugin's own library
|
||||
manifest contributes `android:appCategory="audio"` through the merger, and a
|
||||
`Theme.App.Starting` on the brand ground replaces Android 12+'s white flash —
|
||||
in `values-v31` and `values-night-v31` both, because the qualifier order puts
|
||||
night mode ahead of platform version and a splash written only into
|
||||
`values-v31` is silently ignored on a dark-mode phone.
|
||||
|
||||
The walk gains `media-card`: after the session block, the card is still
|
||||
posted on the `playback` channel with the playing track's title on it, and
|
||||
`media-keys` below it still gets the transport back — which is what a
|
||||
drawable the shade cannot inflate would actually break. The glyph itself and
|
||||
the splash are screenshots; `dumpsys notification` prints a resource id and
|
||||
never a pixel, and the splash is gone before the walk's first frame.
|
||||
@@ -0,0 +1,47 @@
|
||||
android: the media mirror grows a queue window the card never shows
|
||||
|
||||
The Kotlin side of the mirror has always known about exactly one track, and
|
||||
media3's `SimpleBasePlayer.getState()` — the thing an Android Auto session is
|
||||
built on — wants a playlist and an index into it. So the data lands a round
|
||||
before the migration that needs it, where it cannot break anything:
|
||||
`NowPlaying` and `NowPlayingArgs` carry a bounded `queue` of at most
|
||||
twenty-one entries, the current item and ten either side, each with its id,
|
||||
title, artist, album, duration and art key, plus `queueIndex`, where the
|
||||
current one sits within that window rather than within the whole queue.
|
||||
`src/mobile/media.svelte.js` fills them from `player.queue` and `player.qi`;
|
||||
`MediaService` ignores every bit of it, so the card is byte for byte the card
|
||||
it was. Ten either side because one covers the card's own prev and next and
|
||||
nothing else while a queue list — which is what Auto asks for — wants a
|
||||
screenful plus a scroll both ways, and it is a cap rather than a length: a
|
||||
two-thousand-track queue crosses the bridge as twenty-one entries exactly
|
||||
like a twenty-one-track one, measured at two kilobytes of JSON for twenty-one
|
||||
short titles and six with long ones and real cover paths, against the thirty
|
||||
to eighty of base64 cover that already ride the same call once per record.
|
||||
|
||||
Nothing renders it, which is the whole difficulty in checking it — and both
|
||||
hops of this bridge fail silently, because serde drops an unknown field and
|
||||
tauri's Jackson mapper has `FAIL_ON_UNKNOWN_PROPERTIES` off, so a key spelled
|
||||
snake_case where camelCase was wanted arrives as nothing at all with no error
|
||||
anywhere. `MediaSessionPlugin.update` logs one line per update, and the phone
|
||||
walk holds that line against `__ss.card` — the mirror media.svelte.js now
|
||||
keeps of what it last sent, the shape xfernote has kept of the shade since it
|
||||
was written — field for field, through a next and a previous. The art-key
|
||||
count is compared as well as the size and the index, since that is the only
|
||||
camelCase field an entry has. The check arms at queue index one rather than
|
||||
at wherever the walk left off, which buys a row on either side — `prev()`
|
||||
seeks to zero at the top of a queue and `next()` stops at the end — a track
|
||||
that has only just started, so `prev()` is still a step rather than the seek
|
||||
it becomes past five seconds, and a window whose own index is the queue
|
||||
index, because nothing is clamped off the front while the index is under ten.
|
||||
Each of the three readings has to be a fresh one at both ends, the push
|
||||
counter up and a new line in the log, since a `prev()` that lands back where
|
||||
it started reads exactly like a stale line otherwise; and a queue too short
|
||||
to arm, or repeat-one, under which `next()` seeks instead of stepping, fails
|
||||
the check rather than skipping it.
|
||||
|
||||
The plugin's `consumer-rules.pro` keeps the new `@InvokeArg` class by name
|
||||
beside the one it already keeps; without that line a release build would fill
|
||||
`NowPlayingArgs` and leave every queue entry null, quietly. Rust and Kotlin
|
||||
both changed, so this one cannot go down `dev.sh` — it needs the full
|
||||
`deploy.sh` detour. And everything the walk already asserts about the card is
|
||||
untouched, which is the real check on this round.
|
||||
@@ -0,0 +1,50 @@
|
||||
android: the launcher's ground is the desktop's, not a flat colour under it
|
||||
|
||||
The icon round left the adaptive background as one `#FF05080B` and a note that
|
||||
a gradient was being mocked. The mock settles it: the falcon covers about a
|
||||
quarter of a circle-masked tile, so the ground is three quarters of what
|
||||
anyone sees, and concept 04's glow survives every mask — the visible top-left
|
||||
corner goes from `#05080B` to `#0E3543`. So `@color/ic_launcher_background`
|
||||
becomes `@drawable/ic_launcher_ground`, a vector carrying the desktop icon's
|
||||
own linear surface and the radial glow over it, every number
|
||||
`src-tauri/icons/icon.svg`'s multiplied by 108/1024 and nothing else. The
|
||||
colour resource stays where it is, unreferenced, because its filename is the
|
||||
only thing stopping tauri's own `#fff` from colliding with it.
|
||||
|
||||
A vector and not five PNGs on evidence. The kit's glow has a uniform scale, so
|
||||
the SVG ellipse is a circle and its rotation is a no-op, which is exactly what
|
||||
`android:type="radial"` can carry; two numbers there and no vector could have
|
||||
said it. The linear axis is 51.1° off horizontal, which rules out a
|
||||
`<layer-list>` of shapes as well, whose angle must be a multiple of 45. It was
|
||||
compiled and linked with the SDK's own aapt2 over the real merged res tree and
|
||||
every stop read back out of the APK. The name is `ic_launcher_ground` and not
|
||||
`ic_launcher_background` on purpose: the generated project still carries
|
||||
Android Studio's template drawable of that name, so under the other name an
|
||||
overlay copy that failed would silently link the green robot — under this one
|
||||
it is a hard link error, which was tested too.
|
||||
|
||||
`verify-icon.sh` follows the layer. It does not ask whether the file is in the
|
||||
APK; it asks what the adaptive icon's `<background>` resolves to, through the
|
||||
resource table, in both build types, and then reads concept 04's six stops back
|
||||
out of what it found — which catches a stale APK as well as a lost overlay.
|
||||
Passing on a debug APK and on one put through `--shorten-resource-paths`,
|
||||
failing on a stale one, and saying NOT CHECKED rather than guessing when aapt2
|
||||
is absent. What none of it can see is a pixel: banding on the OLED, the glow's
|
||||
weight under the Pixel's mask and how the falcon reads on a lit ground are a
|
||||
launcher screenshot on the phone, and ANDROID.md says so instead of implying
|
||||
the check covers it.
|
||||
|
||||
One slot the ground reaches is not the launcher at all. `MediaHub.appIcon()`
|
||||
draws whatever `getApplicationIcon()` returns into a 512 px bitmap and hands
|
||||
it to the lock-screen card as the large icon for any track whose cover has
|
||||
not been decoded yet, so the card and the shade carry this ground as well —
|
||||
the quickest place for Kyle to look at it, and the only reader of the
|
||||
launcher icon inside the app. The walk reads that card's title and the
|
||||
session's playback state and never its icon, so no step changes meaning. The
|
||||
Android 12+ splash very likely changes as well, and the other way from what
|
||||
the docs' one-line version of the rule suggests: the platform drops an
|
||||
adaptive background only when it is a single colour close to
|
||||
`windowSplashScreenBackground`, which round 44's flat `#05080B` was, so the
|
||||
falcon has been drawing there alone and enlarged; a gradient is not one
|
||||
colour, so it is kept and the whole icon is drawn. Likely, not proven — that
|
||||
one is a screenshot too.
|
||||
@@ -0,0 +1,61 @@
|
||||
android: the lock-screen card, moved to media3 before anything is built on it
|
||||
|
||||
The card Kyle sees every day was a hand-built MediaStyle notification over a
|
||||
MediaSessionCompat, on `androidx.media`, deprecated since May. Android Auto
|
||||
needs a media3 session to browse, so the choice was to write the new thing
|
||||
against a deprecated library or to move the old thing first; this moves the
|
||||
old thing and adds no Auto. `MediaService` is a `MediaSessionService` and a
|
||||
new `SoulStreamPlayer` is the `SimpleBasePlayer` media3 asks for when the
|
||||
engine lives somewhere else — rodio, in Rust, behind a WebView round trip —
|
||||
so `getState()` is a pure read of the last `NowPlayingArgs` off the bridge,
|
||||
queue window and all, and every button routes back out through `MediaHub.act`.
|
||||
|
||||
Three corrections to the plan, each the kind that only shows up on a phone.
|
||||
media3 **1.10.1**, not 1.11.0, whose POM drags kotlin-stdlib 2.2.10 into a
|
||||
project that compiles with Kotlin 1.9.25. The audio-focus and becoming-noisy
|
||||
blocks STAY hand-rolled: `setAudioAttributes(…, handleAudioFocus)` is an
|
||||
ExoPlayer convenience that lands in a `@ForOverride` method which throws, and
|
||||
deleting them would have lost "another app takes the output" and "headphones
|
||||
out" with nothing failing anywhere. And the service is BOUND, never started —
|
||||
`MediaSessionService.onStartCommand` returns `START_STICKY` without promoting
|
||||
for any intent that is not media3's own, so a `startForegroundService` would
|
||||
arm the ten-second kill timer and satisfy nothing, which is the 2026-09-13 bug
|
||||
in a new coat. Four more things are load-bearing and silent when they break: a
|
||||
playlist that must never be empty while a track exists, `COMMAND_GET_TIMELINE`
|
||||
with `COMMAND_GET_CURRENT_MEDIA_ITEM`, `STATE_READY` while paused, and both
|
||||
spellings of next and previous. Each ends in a card that is simply never
|
||||
posted, with nothing in any log. The window carries `queueBase` now, for a
|
||||
stable media-item uid as it slides, and `repeat`, whose numbering is swapped
|
||||
between the app and media3, so that what a controller is told about repeat is
|
||||
the truth and media3's own view of the end of the queue agrees with the
|
||||
`hasNext` the next button is advertised on.
|
||||
|
||||
The 2026-09-13 bug has a second door, and `onStartCommand` is what shuts it.
|
||||
media3 hands the framework a media-button `PendingIntent` built with
|
||||
`PendingIntent.getForegroundService`, unconditionally, because the receiver the
|
||||
legacy stub resolves is now this service — there being no `MediaButtonReceiver`
|
||||
left for it to find. So the SYSTEM starts this service in the foreground on
|
||||
every media KEY, while media3 promotes only for a session it calls
|
||||
user-engaged. A key into a process built for that key, a key on a card paused
|
||||
past the ten-minute tail that does not start playback, any key with the bar
|
||||
cleared: none of them qualify, and each would take the app down ten seconds
|
||||
later. (The card's own buttons are milder — only play/pause, and only while
|
||||
paused, is a foreground start.) So `onStartCommand` promotes first, the way
|
||||
this service always did — through media3's own `onUpdateNotification` when
|
||||
there is a card to post, and otherwise with a plain notification of its own,
|
||||
posted and withdrawn in the same breath. `Service`'s
|
||||
`startForeground` is final, so the split is by what media3 can be asked to do,
|
||||
not by watching it.
|
||||
|
||||
The walk gains `one-card` and `one-session` — the only checks that tell a
|
||||
landed migration from a doubled one — plus an advisory `media-plumbing` line;
|
||||
the logged window grew `base` and `rep`, which are the only readback either
|
||||
field has anywhere on the device and which `queue-window` now asserts; and the
|
||||
paused-restore wait goes to 25 s, which ANDROID.md had already claimed in one
|
||||
place and contradicted in another. ANDROID.md also says plainly what the walk
|
||||
cannot prove and Kyle has to try himself: headphones out, another app taking
|
||||
focus, the card's actual look, and a Bluetooth headset's own buttons. The
|
||||
Kotlin is unbuilt — no Kotlin compiler here — and was read against javap of
|
||||
media3 1.10.1 and the real sources of `SimpleBasePlayer`, `MediaSessionService`,
|
||||
`MediaNotificationManager` and `DefaultMediaNotificationProvider`; the manifest
|
||||
and resources compile and link under aapt2, and the Rust type-checks.
|
||||
@@ -0,0 +1,185 @@
|
||||
<script>
|
||||
/* A stranger, in full — the desktop's person drawer as a screen: the
|
||||
card their own client serves (what they wrote about themselves, their
|
||||
picture, what their uploads are doing), the likes and hates they
|
||||
declared to the server, and the comparison this phone makes of their
|
||||
share against the library. The comparison is the reason to open a
|
||||
stranger at all: every record they have by an artist in your library
|
||||
that you don't. Nothing is uploaded to work it out — their
|
||||
folder names against your albums, here (`overlap.rs`). */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Identicon from "../../components/Identicon.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { slsk, openThread } from "../slsk.svelte.js";
|
||||
import { push, openHalf, openViewer } from "../nav.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { note } from "../notice.svelte.js";
|
||||
import { ago } from "../util.js";
|
||||
|
||||
let { username, screenEl = null } = $props();
|
||||
|
||||
/* the comparison keeps 120 named pairs; a screenful shows, the rest on
|
||||
demand — the cap Browse gives a big share */
|
||||
const GAPS_CAP = 40;
|
||||
let info = $state(null); // slsk_user_info, once their client answers
|
||||
let infoErr = $state("");
|
||||
let interests = $state(null); // slsk_user_interests, from the server
|
||||
let intErr = $state(false);
|
||||
let overlap = $state(null); // the comparison, if this device made one
|
||||
let busy = $state(false); // a comparison running: it browses them first
|
||||
let cap = $state(GAPS_CAP);
|
||||
|
||||
const online = $derived(slsk.connection === "online");
|
||||
const picture = $derived(info?.picture_png_base64 ? "data:image/png;base64," + info.picture_png_base64 : null);
|
||||
const likes = $derived(interests?.likes ?? []);
|
||||
const hates = $derived(interests?.hates ?? []);
|
||||
|
||||
/* both asked at once. The card comes from THEIR client, direct and peer
|
||||
to peer, so it can simply go unanswered; the interests come from the
|
||||
server, which answers or doesn't. */
|
||||
$effect(() => {
|
||||
const u = username;
|
||||
if (!u || !online) return;
|
||||
let live = true;
|
||||
info = null;
|
||||
infoErr = "";
|
||||
interests = null;
|
||||
intErr = false;
|
||||
api.slskUserInfo(u).then((v) => { if (live) info = v; }, (e) => { if (live) infoErr = String(e).replace(/^Error:\s*/, ""); });
|
||||
api.slskUserInterests(u).then((v) => { if (live) interests = v; }, () => { if (live) intErr = true; });
|
||||
return () => { live = false; };
|
||||
});
|
||||
|
||||
/* the ledger is per username and kept on the device: opening a stranger
|
||||
reads what was compared before, never compares — comparing browses
|
||||
their whole share, which is a minute of someone else's bandwidth */
|
||||
async function readOverlap() {
|
||||
try {
|
||||
const all = (await api.overlapList()) || [];
|
||||
const k = String(username || "").trim().toLowerCase();
|
||||
overlap = all.find((o) => String(o.username).toLowerCase() === k) ?? null;
|
||||
} catch {
|
||||
overlap = null;
|
||||
}
|
||||
}
|
||||
$effect(() => { void username; cap = GAPS_CAP; void readOverlap(); });
|
||||
|
||||
async function compare() {
|
||||
if (busy || !online) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.overlapCompute(username);
|
||||
await readOverlap();
|
||||
cap = GAPS_CAP;
|
||||
} catch (e) { player.error = String(e); }
|
||||
busy = false;
|
||||
}
|
||||
async function forget() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await api.overlapForget(username);
|
||||
overlap = null;
|
||||
note("Comparison forgotten");
|
||||
} catch (e) { player.error = String(e); }
|
||||
busy = false;
|
||||
}
|
||||
/* a record they have and the library doesn't: the catalog search that
|
||||
finds it, where Save can fetch it from whoever has it best */
|
||||
function findRecord(g) {
|
||||
push("search", { q: [g.artist, g.album].filter(Boolean).join(" ") }, screenEl);
|
||||
}
|
||||
const uploadLine = $derived.by(() => {
|
||||
if (!online) return "not signed in to Soulseek";
|
||||
if (infoErr) return "their client did not answer";
|
||||
if (!info) return "asking their client…";
|
||||
return (info.has_free_slot ? "a slot is free" : "no free slot")
|
||||
+ " · queue " + (info.queue_size ?? 0)
|
||||
+ " · " + (info.total_uploads ?? 0).toLocaleString() + " served";
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="brw-head pr-head">
|
||||
{#if picture}
|
||||
<button class="pr-pic" onclick={() => openViewer(picture)} aria-label="Their picture"><img src={picture} alt="" /></button>
|
||||
{:else}
|
||||
<Identicon seed={username} size={44} />
|
||||
{/if}
|
||||
<div class="b">
|
||||
<div class="t">{username}</div>
|
||||
<div class="m">{uploadLine}</div>
|
||||
</div>
|
||||
<button class="iconbtn muted" onclick={() => openHalf("peer", { username })} aria-label="More"><Icon name="more" size={22} /></button>
|
||||
</div>
|
||||
|
||||
<div class="brw-acts">
|
||||
<button class="btn btn-fill" onclick={() => { openThread(username); push("thread", { username }, screenEl); }}><Icon name="message" size={14} />Message</button>
|
||||
<button class="btn btn-ghost" onclick={() => push("browse", { username }, screenEl)}><Icon name="folder-open" size={14} />Browse their files</button>
|
||||
</div>
|
||||
|
||||
{#if !online}
|
||||
<div class="sec-note">Sign in to Soulseek to ask their client for their card.</div>
|
||||
{:else if infoErr}
|
||||
<div class="sec-note">Their client did not answer ({infoErr}) — offline, firewalled, or not sharing a card.</div>
|
||||
{:else if !info}
|
||||
<div class="sec-note">Asking their client for the card — direct, peer to peer.</div>
|
||||
{:else}
|
||||
<div class="pr-about">{info.description?.trim() || "They wrote nothing about themselves."}</div>
|
||||
{#if info.picture_note}<div class="sec-note">{info.picture_note}</div>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="sec"><h2>Interests <em>· what they declared on Soulseek</em></h2></div>
|
||||
{#if likes.length || hates.length}
|
||||
{#if likes.length}
|
||||
<div class="sec-note">Likes</div>
|
||||
<div class="chips pr-tags">{#each likes as l, i (i)}<span class="chip quiet">{l}</span>{/each}</div>
|
||||
{/if}
|
||||
{#if hates.length}
|
||||
<div class="sec-note" style="padding-top: 10px">Hates</div>
|
||||
<div class="chips pr-tags">{#each hates as h, i (i)}<span class="chip quiet">{h}</span>{/each}</div>
|
||||
{/if}
|
||||
{:else if intErr}
|
||||
<div class="sec-note">The server did not answer.</div>
|
||||
{:else if interests}
|
||||
<div class="sec-note">None declared.</div>
|
||||
{:else if online}
|
||||
<div class="sec-note">Asking the server…</div>
|
||||
{:else}
|
||||
<div class="sec-note">Sign in to Soulseek to see what they declared.</div>
|
||||
{/if}
|
||||
|
||||
<div class="sec"><h2>Library overlap <em>· compared on this phone</em></h2></div>
|
||||
{#if overlap}
|
||||
{@const o = overlap}
|
||||
<div class="sec-note pr-sum">
|
||||
{o.their_albums
|
||||
? `${o.albums_in_common_count} of their ${o.their_albums} albums are in your library · ${o.artists_in_common_count} artist${o.artists_in_common_count === 1 ? "" : "s"} in common · compared ${ago(o.computed_at)}`
|
||||
: `No album folders among ${o.audio_files} audio files · compared ${ago(o.computed_at)}`}
|
||||
</div>
|
||||
{#if o.gaps.length}
|
||||
<div class="sec-note" style="padding-top: 6px">They have {o.gaps_count} record{o.gaps_count === 1 ? "" : "s"} by artists in your library that you don't.{o.gaps.length > cap ? ` The first ${cap} are here.` : ""}</div>
|
||||
{#each o.gaps.slice(0, cap) as g, i (i)}
|
||||
<button class="row pr-gap" onclick={() => findRecord(g)}>
|
||||
<span class="lead"><Icon name="disc" size={20} /></span>
|
||||
<span class="b"><span class="t">{g.album}</span><span class="m">{g.artist}</span></span>
|
||||
<span class="r"><Icon name="search" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if o.gaps.length > cap}
|
||||
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => (cap += 200)}>+ {o.gaps.length - cap} more records</button></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="sec-note" style="padding-top: 6px">Nothing they have that you lack.</div>
|
||||
{/if}
|
||||
<div class="brw-acts" style="padding-top: 8px">
|
||||
<button class="btn btn-ghost" onclick={compare} disabled={!online || busy}><Icon name="refresh" size={14} />{busy ? "Browsing…" : "Compare again"}</button>
|
||||
<button class="btn btn-ghost" onclick={forget} disabled={busy}><Icon name="trash" size={14} />Forget this comparison</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stub"><b>Not compared yet.</b>Their share folders against your albums, on this phone — nothing is uploaded to do it.</div>
|
||||
<div style="padding: 0 var(--pad) 8px">
|
||||
<button class="btn btn-ghost wide" onclick={compare} disabled={!online || busy}><Icon name="library" size={14} />{busy ? "Browsing their shares…" : "Compare with my library"}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="endpad"></div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<button class="row bud" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
||||
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{display(b.u)}{#if b.m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if b.m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span>
|
||||
<span class="m">{[realName(b.u), status(b.st), b.m.group, b.st.file_count ? `${b.st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}</span>
|
||||
</span>
|
||||
@@ -0,0 +1,8 @@
|
||||
/* the two local marks beside a name: a star for a favourite, a shield for
|
||||
someone trusted. Both are this phone's own; neither is published. */
|
||||
.nmark { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--text-muted); }
|
||||
.nmark.fav { color: #e8a33d; }
|
||||
.nmark.tr { color: #3ddc84; }
|
||||
/* a room line whose author wears a petname keeps their own name beside it */
|
||||
.msg .who .real { margin-left: 5px; font-weight: 600; opacity: 0.65; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Every name in Community can wear one you gave it. The peer's sheet ends in
|
||||
**Name and notes** (`src/mobile/contacts.svelte.js`, `contacts_meta_list` /
|
||||
`contact_meta_set`): a petname, a group, a private note and the two marks,
|
||||
Favorite and Trusted. All of it sits in the app's own config on this phone
|
||||
and is published nowhere — Soulseek never learns it, and neither does the
|
||||
person it describes. The petname stands in for the username on the buddy
|
||||
list, in the chat list, on a thread's bar, in the peer sheet, in a room's
|
||||
roster and on a room line, and the real username stays with it — on the
|
||||
line beneath, or beside the name in a room — so a stranger is never quietly
|
||||
turned into someone you know. Favourites sit at the top of the buddy list,
|
||||
a star and a shield mark the two, and clearing a person's details drops the
|
||||
entry rather than storing an empty one — the backend's own rule.
|
||||
@@ -0,0 +1,11 @@
|
||||
"user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
|
||||
/* the local contact metadata: the editor, the petname reaching the
|
||||
backend, the mark sticking, the buddy list rearranged — and the
|
||||
cleared entry dropped rather than stored empty */
|
||||
"contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere",
|
||||
"contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "),
|
||||
"contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText),
|
||||
"contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite",
|
||||
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
|
||||
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
|
||||
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
|
||||
@@ -0,0 +1,5 @@
|
||||
budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||
budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'),
|
||||
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
||||
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
||||
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
||||
@@ -0,0 +1,10 @@
|
||||
/* the contact editor's three fields go through the prompt sheet. The
|
||||
username travels as a VALUE, the way the playlist prompts do: the
|
||||
sheet switches kind under the prompt, so the contact block's
|
||||
{@const} is a destroyed derived by the time Save lands. */
|
||||
function metaPrompt(u, field, title, placeholder, value, multiline = false) {
|
||||
openHalf("prompt", {
|
||||
title, value, placeholder, ok: "Save", multiline, allowEmpty: true,
|
||||
onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{:else if kind === "contact" && shown?.arg?.username}
|
||||
{@const u = shown.arg.username}
|
||||
{@const m = metaOf(u)}
|
||||
<div class="sheet-head">
|
||||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||
<span class="b"><span class="t">{display(u)}</span><span class="m">{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))}
|
||||
{@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))}
|
||||
{@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))}
|
||||
<div class="sheet-hair"></div>
|
||||
<button class="row" onclick={() => setMeta(u, { favorite: !m.favorite })}>
|
||||
<span class="lead"><Icon name="star" size={20} /></span>
|
||||
<span class="b"><span class="t">Favorite</span><span class="m">kept at the top of the buddy list</span></span>
|
||||
<span class="sw" class:on={m.favorite}><span></span></span>
|
||||
</button>
|
||||
<button class="row" onclick={() => setMeta(u, { trusted: !m.trusted })}>
|
||||
<span class="lead"><Icon name="shield" size={20} /></span>
|
||||
<span class="b"><span class="t">Trusted</span><span class="m">someone you take at their word</span></span>
|
||||
<span class="sw" class:on={m.trusted}><span></span></span>
|
||||
</button>
|
||||
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
/* the contact editor behind the peer sheet: a petname, a note, the
|
||||
favourite mark — then the buddy list wearing all three, ada's own
|
||||
name still under it and favourites first */
|
||||
{ do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 },
|
||||
{ do: `__click('.halfsheet .row', 'Petname')`, wait: 500 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Note')`, wait: 500 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 },
|
||||
{ do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 },
|
||||
@@ -0,0 +1,3 @@
|
||||
/* the room's roster wears the petname too, the real name beneath it */
|
||||
{ do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||||
@@ -0,0 +1,6 @@
|
||||
/* nothing set is nothing stored: cleared, the entry leaves the list and
|
||||
a name goes back to being a name */
|
||||
{ do: `__click('.segtabs button', 'Buddies')`, wait: 600 },
|
||||
{ do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 },
|
||||
{ do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 },
|
||||
{ do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 },
|
||||
@@ -0,0 +1,70 @@
|
||||
/* The local view of a person on the network: a petname, a group, a private
|
||||
note and the two marks. All of it lives in this app's own config and is
|
||||
published nowhere — Soulseek never learns it, and neither does the person
|
||||
it describes. The desktop keeps the same index by username
|
||||
(src/lib/community.svelte.js), and the same rule: the backend drops an
|
||||
entry with nothing set, so the list it answers with is the one we take. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
|
||||
export const contacts = $state({
|
||||
/** username → { username, petname, group, note, trusted, favorite } */
|
||||
by: {},
|
||||
/** the list came back; false until it does, and after a failure */
|
||||
loaded: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const BLANK = { petname: null, group: null, note: null, trusted: false, favorite: false };
|
||||
/** what the backend stores: a trimmed string, or nothing at all */
|
||||
const trimmed = (v) => { const s = typeof v === "string" ? v.trim() : v; return s ? s : null; };
|
||||
|
||||
function index(list) {
|
||||
contacts.by = Object.fromEntries((list || []).map((c) => [c.username, c]));
|
||||
contacts.loaded = true;
|
||||
contacts.error = "";
|
||||
}
|
||||
|
||||
let inflight = null;
|
||||
/** Read the whole list — once at boot, and again whenever anyone asks. A
|
||||
* failure is quiet: a petname is an enhancement, not the screen. */
|
||||
export function loadContacts() {
|
||||
if (inflight) return inflight;
|
||||
inflight = api
|
||||
.contactsMetaList()
|
||||
.then(index)
|
||||
.catch((e) => { contacts.loaded = false; contacts.error = String(e); })
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export const metaOf = (u) => contacts.by[u] ?? { username: u, ...BLANK };
|
||||
/** The name to show: the petname when there is one, the username otherwise. */
|
||||
export const display = (u) => contacts.by[u]?.petname || u;
|
||||
/** The username, but only while a petname stands in front of it — a stranger
|
||||
* is never quietly turned into someone you know. */
|
||||
export const realName = (u) => (contacts.by[u]?.petname ? u : "");
|
||||
|
||||
/** Upsert one person. The answer is the whole list after the write, so it is
|
||||
* taken as it comes rather than patched into our own copy: that is how an
|
||||
* entry with nothing left in it disappears. A failure goes to the bar. */
|
||||
export async function setMeta(username, patch) {
|
||||
const m = { ...metaOf(username), ...patch };
|
||||
try {
|
||||
index(
|
||||
await api.contactMetaSet({
|
||||
username,
|
||||
petname: trimmed(m.petname),
|
||||
group: trimmed(m.group),
|
||||
note: trimmed(m.note),
|
||||
trusted: !!m.trusted,
|
||||
favorite: !!m.favorite,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
player.error = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything blank: the entry leaves the list rather than being stored empty. */
|
||||
export const clearMeta = (u) => setMeta(u, { petname: "", group: "", note: "", trusted: false, favorite: false });
|
||||
@@ -0,0 +1,35 @@
|
||||
{:else if tab === "taste"}
|
||||
<div class="sec" style="padding-top:8px"><h2>What you like <em>· {taste.likes.length}</em></h2></div>
|
||||
<div class="sec-note">Declared on Soulseek: this is what the server matches other people against.</div>
|
||||
<div class="chips tastechips likes" style="padding-top:8px">
|
||||
{#each taste.likes as thing (thing)}
|
||||
<button class="chip on tchip" disabled={taste.busy} onclick={() => setInterest(thing, true, false)}>{thing}<Icon name="x" size={13} /></button>
|
||||
{/each}
|
||||
<button class="chip quiet tadd" disabled={taste.busy} onclick={() => declare(true)}><Icon name="plus" size={14} />Add a like</button>
|
||||
</div>
|
||||
<div class="sec" style="padding-top:18px"><h2>What you hate <em>· {taste.hates.length}</em></h2></div>
|
||||
<div class="chips tastechips hates" style="padding-top:8px">
|
||||
{#each taste.hates as thing (thing)}
|
||||
<button class="chip tchip" disabled={taste.busy} onclick={() => setInterest(thing, false, false)}>{thing}<Icon name="x" size={13} /></button>
|
||||
{/each}
|
||||
<button class="chip quiet tadd" disabled={taste.busy} onclick={() => declare(false)}><Icon name="ban" size={14} />Add a hate</button>
|
||||
</div>
|
||||
<div class="sec" style="padding-top:18px"><h2>People like you{#if taste.similar?.users?.length} <em>· {taste.similar.users.length}</em>{/if}</h2></div>
|
||||
{#if taste.asking && !taste.similar}
|
||||
<div class="sec-note">Asking the server…</div>
|
||||
{:else if taste.similar?.users?.length}
|
||||
{#each taste.similar.users.slice(0, 40) as su (su.username)}
|
||||
<button class="row similar" onclick={() => openHalf("peer", { username: su.username })}>
|
||||
<span class="art round idc"><Identicon seed={su.username} size={46} /></span>
|
||||
<span class="b"><span class="t">{su.username}</span><span class="m">shares {su.rating} of your {su.rating === 1 ? "like" : "likes"}</span></span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if taste.error}
|
||||
<div class="stub"><b>The server did not answer.</b>{taste.error}</div>
|
||||
{:else if !taste.likes.length}
|
||||
<div class="stub"><b>Nothing to match you on yet.</b>The server puts people together on the likes you declare — declare one above and it has something to go on.</div>
|
||||
{:else}
|
||||
<div class="stub"><b>Nobody yet.</b>The server found no one close to what you declared.</div>
|
||||
{/if}
|
||||
<div style="padding: 6px var(--pad) 0"><button class="btn btn-ghost wide" disabled={taste.asking} onclick={() => askSimilar(true)}>Ask the server again</button></div>
|
||||
@@ -0,0 +1,11 @@
|
||||
Community's fourth tab is **Taste**: the interests this phone has
|
||||
declared to the Soulseek server (`slsk_interests_mine`, `slsk_interest_set`)
|
||||
and the people the server ranks as close to them (`slsk_similar_users`), a
|
||||
row each into the peer sheet. A like or a hate is a chip and tapping one
|
||||
takes it back; "Add a like" and "Add a hate" declare a new one. Every
|
||||
declaration asks the server again and the chips stay quiet until it has
|
||||
answered, because the ranking only moves when its input does. The lists
|
||||
are this client's own — the backend keeps them and re-declares every one
|
||||
at login, since the server's copy belongs to the account and another
|
||||
client signed in elsewhere can change it under us. Mock-walked only: a
|
||||
live sign-in and the server's own ranking are Kyle's to see.
|
||||
@@ -0,0 +1,13 @@
|
||||
/* your own interests: what is declared, a like added and the server's
|
||||
match re-asked, a hate added, the like taken back, a match's sheet */
|
||||
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 },
|
||||
{ do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 },
|
||||
{ do: `__click('.tastechips.likes .tadd', null)`, wait: 500 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 },
|
||||
{ do: `__click('.tastechips.hates .tadd', null)`, wait: 500 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 },
|
||||
{ do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 },
|
||||
{ do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||||
@@ -0,0 +1,80 @@
|
||||
/* Taste: what this device has told the Soulseek server it likes and what
|
||||
it hates, and the people the server ranks as close to it. The desktop
|
||||
keeps all three in `community.svelte.js` and follows one rule — the
|
||||
ranking only moves when its input does, so every declaration asks the
|
||||
server again and the chips stay quiet until it has answered. The lists
|
||||
are THIS CLIENT's: the backend keeps them and re-declares every one of
|
||||
them at login, because the server's own copy belongs to the account and
|
||||
another client signed in elsewhere can change it under us. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { slsk } from "./slsk.svelte.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
|
||||
export const taste = $state({
|
||||
/** the things declared, as the backend last answered */
|
||||
likes: [],
|
||||
hates: [],
|
||||
/** null until asked; { users: [{ username, rating }] } after */
|
||||
similar: null,
|
||||
/** a declaration in flight, so the chips go quiet */
|
||||
busy: false,
|
||||
asking: false,
|
||||
/** the server's word when it would not answer */
|
||||
error: "",
|
||||
});
|
||||
|
||||
/* the account these lists were read for — a plain variable, never $state:
|
||||
the screen's effect is what calls loadTaste, and reading this inside it
|
||||
would arm that effect on our own write */
|
||||
let readFor = null;
|
||||
|
||||
/** What this device has declared. Once per account, unless forced. */
|
||||
export async function loadTaste(force = false) {
|
||||
const who = slsk.username || slsk.savedUser || "";
|
||||
if (readFor === who && !force) return;
|
||||
readFor = who;
|
||||
try {
|
||||
const v = await api.slskInterestsMine();
|
||||
taste.likes = v?.likes ?? [];
|
||||
taste.hates = v?.hates ?? [];
|
||||
} catch (e) {
|
||||
player.error = String(e);
|
||||
}
|
||||
await askSimilar(true);
|
||||
}
|
||||
|
||||
/** Declare a thing, or take it back. The backend answers with both lists,
|
||||
* and the server's match is asked again before the chips come back — it
|
||||
* ranks on exactly this, so a second declaration must not overtake it. */
|
||||
export async function setInterest(thing, like, on) {
|
||||
const t = String(thing || "").trim();
|
||||
if (!t || taste.busy) return false;
|
||||
taste.busy = true;
|
||||
try {
|
||||
const v = await api.slskInterestSet(t, like, on);
|
||||
taste.likes = v?.likes ?? [];
|
||||
taste.hates = v?.hates ?? [];
|
||||
await askSimilar(true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
player.error = String(e);
|
||||
return false;
|
||||
} finally {
|
||||
taste.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The people the server puts near us, by the likes we declared. */
|
||||
export async function askSimilar(force = false) {
|
||||
if ((taste.similar && !force) || taste.asking) return;
|
||||
taste.asking = true;
|
||||
taste.error = "";
|
||||
try {
|
||||
taste.similar = { users: (await api.slskSimilarUsers()) || [] };
|
||||
} catch (e) {
|
||||
taste.similar = { users: [] };
|
||||
taste.error = String(e);
|
||||
} finally {
|
||||
taste.asking = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
The rest of the lyrics tools came with it. The sheet carries the per-track
|
||||
auto-sync switch (`lyrics_set_auto_sync`), so the timing correction the app
|
||||
makes from a file's own lead-in can be turned off for one recording and back
|
||||
on. "Re-time by hand" opens the tap-to-sync room (`SyncRoom.svelte`, one
|
||||
more layer the back gesture peels): the song plays, the line to time sits
|
||||
large in the middle of the screen with the one behind and the one ahead
|
||||
faint around it, and a tap anywhere stamps it — the last tap undone, −5 s
|
||||
and play/pause under the thumb, and Save writing the track's own document
|
||||
(`lyrics_save_lines`, the top of the waterfall, where a paste lands too).
|
||||
Lines never tapped are left out of it and the button says how many are kept;
|
||||
a set left mid-tap by the back gesture is offered back by the row that
|
||||
opened it. "Play with reduced vocals" is the desktop's offline DSP
|
||||
(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a
|
||||
separator, and the wording stays honest about that): the copy plays under
|
||||
the same title, artist and duration, so the lyric identity is still the
|
||||
recording's and the words keep following it, and one row puts the original
|
||||
back.
|
||||
@@ -0,0 +1,6 @@
|
||||
{#if lyr.lines.length || lyr.plain}
|
||||
{@render row("clock", lyr.lines.length ? "Re-time by hand" : "Sync these lyrics", tapLeft ? `${tapLeft.done} of ${tapLeft.total} lines timed — keep going` : "play the song and tap as each line begins", act(openTapSync))}
|
||||
{/if}
|
||||
{#if canReduce}
|
||||
{@render row("music", onReduced ? "Back to the original" : "Play with reduced vocals", onReduced ? "the recording as it is" : lyr.reducing ? "making the copy now — a moment" : "a rough centre cancel, made on this phone", act(() => { void (onReduced ? backToOriginal() : reduceVocals()); }))}
|
||||
{/if}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
{#snippet swrow(icon, t, m, on, fn)}
|
||||
<button class="row" onclick={fn}>
|
||||
<span class="lead"><Icon name={icon} size={20} /></span>
|
||||
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
|
||||
<span class="sw" class:on><span></span></span>
|
||||
</button>
|
||||
{/snippet}
|
||||
@@ -0,0 +1,153 @@
|
||||
|
||||
/** Re-read the payload for the loaded track without emptying the panel —
|
||||
* a switch flips and the words stay on screen. The cache only, never the
|
||||
* network. */
|
||||
async function reread() {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
const g = gen;
|
||||
try {
|
||||
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: null });
|
||||
if (g === gen) apply(p, true);
|
||||
} catch { /* what is showing stands */ }
|
||||
}
|
||||
|
||||
/** The app's automatic timing correction, on or off for this track. The
|
||||
* switch flips at once; the backend's own number comes with the re-read. */
|
||||
export async function setAutoSync(enabled) {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
const before = lyr.autoSyncOff;
|
||||
lyr.autoSyncOff = !enabled;
|
||||
try {
|
||||
await api.lyricsSetAutoSync(id.title, id.artist, id.durationSecs, enabled);
|
||||
} catch (e) {
|
||||
lyr.autoSyncOff = before;
|
||||
player.error = String(e);
|
||||
return;
|
||||
}
|
||||
await reread();
|
||||
}
|
||||
|
||||
/* ── the vocal-reduced copy ──────────────────────────────────────────────
|
||||
The desktop's offline DSP: a band-limited centre cancel, not a learned
|
||||
separator, and the wording stays honest about that. The copy plays under
|
||||
the SAME title, artist and duration, so the lyric identity is still the
|
||||
recording's and the words keep following it. The track starts again from
|
||||
the top — there is no seeking a file the engine has only just been given. */
|
||||
async function swapPath(e, to) {
|
||||
/* a COPY of the entry's track, never the row itself: tracksOf caches one
|
||||
array per record and hands the SAME objects to the record page and to
|
||||
the queue, so writing the path in place would leave the record pointing
|
||||
at a cache file the LRU drops out from under it. The desktop does not
|
||||
rewrite its queue item either — it plays the copy as its own thing. */
|
||||
e.t = { ...e.t, path: to };
|
||||
await playQueueAt(player.qi);
|
||||
}
|
||||
export async function reduceVocals() {
|
||||
const e = current();
|
||||
const t = e?.t;
|
||||
if (!t?.path || t.stream || lyr.reducing) return false;
|
||||
const from = t.path;
|
||||
lyr.reducing = true;
|
||||
note("Reducing the vocals — this can take a moment");
|
||||
try {
|
||||
const res = await api.lyricsPrepareInstrumental(from);
|
||||
/* the render takes seconds: starting the OLD track's copy now would
|
||||
hijack whatever the queue has moved on to */
|
||||
const now = current();
|
||||
if (!res?.path || now?.t?.path !== from) {
|
||||
note("The track changed while preparing — try again");
|
||||
return false;
|
||||
}
|
||||
lyr.reduced = { from, to: res.path };
|
||||
await swapPath(now, res.path);
|
||||
return true;
|
||||
} catch (err) {
|
||||
player.error = String(err);
|
||||
return false;
|
||||
} finally {
|
||||
lyr.reducing = false;
|
||||
}
|
||||
}
|
||||
export async function backToOriginal() {
|
||||
const r = lyr.reduced;
|
||||
const e = current();
|
||||
if (!r || e?.t?.path !== r.to) return false;
|
||||
await swapPath(e, r.from);
|
||||
lyr.reduced = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── the tap-to-sync room ────────────────────────────────────────────────
|
||||
The song plays and one tap lands per line; the taps ARE the timing.
|
||||
Saving writes the track's own document (lyrics_save_lines), the top of
|
||||
the waterfall, the same shelf a paste lands on — and the backend drops
|
||||
every line without a time, so a half-tapped set is stored as the lines
|
||||
that have one. */
|
||||
export const tapSync = $state({
|
||||
rows: [], // [{ time_ms: number|null, text }]
|
||||
cursor: 0, // the row the next tap stamps
|
||||
identity: null, // the track the rows belong to, captured at open
|
||||
saving: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim())
|
||||
.filter((l) => l && !/^\[.*\]$/.test(l));
|
||||
|
||||
/** Open the room for the playing track. An already-timed set is re-tapped
|
||||
* from scratch: the room shows three lines, not a list, so it has no
|
||||
* per-line handle to nudge — it has the tap. */
|
||||
export function openTapSync() {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
/* peeled by the back gesture mid-tap: the same track's stamps come back
|
||||
rather than the room starting over */
|
||||
if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; }
|
||||
const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain))
|
||||
.filter((t) => String(t || "").trim());
|
||||
if (!texts.length) return;
|
||||
tapSync.rows = texts.map((text) => ({ time_ms: null, text }));
|
||||
tapSync.cursor = 0;
|
||||
tapSync.identity = id;
|
||||
tapSync.saving = false;
|
||||
tapSync.error = "";
|
||||
openTiming();
|
||||
}
|
||||
export function closeTapSync() {
|
||||
closeTiming();
|
||||
tapSync.rows = [];
|
||||
tapSync.cursor = 0;
|
||||
tapSync.identity = null;
|
||||
tapSync.saving = false;
|
||||
tapSync.error = "";
|
||||
}
|
||||
/** What the lyrics sheet says under the row when a set was left mid-tap. */
|
||||
export function tapProgress() {
|
||||
const id = identity();
|
||||
if (!id || tapSync.identity?.key !== id.key) return null;
|
||||
const done = tapSync.rows.filter((r) => r.time_ms != null).length;
|
||||
return done ? { done, total: tapSync.rows.length } : null;
|
||||
}
|
||||
/** The taps, as this track's own timed document. */
|
||||
export async function saveTapSync() {
|
||||
const id = tapSync.identity;
|
||||
const rows = tapSync.rows.filter((r) => r.time_ms != null && r.text.trim());
|
||||
if (!id || !rows.length || tapSync.saving) return false;
|
||||
tapSync.saving = true;
|
||||
tapSync.error = "";
|
||||
try {
|
||||
const p = await api.lyricsSaveLines(id.title, id.artist, id.durationSecs,
|
||||
rows.map((r) => ({ time_ms: Math.max(0, Math.round(r.time_ms)), text: r.text })));
|
||||
/* the same track is still playing: show the result at once */
|
||||
if (identity()?.key === id.key && p && p.status) apply(p);
|
||||
closeTapSync();
|
||||
return true;
|
||||
} catch (e) {
|
||||
tapSync.error = String(e).replace(/^Error:\s*/, "").slice(0, 140);
|
||||
return false;
|
||||
} finally {
|
||||
tapSync.saving = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
if (cmd === "lyrics_set_auto_sync") { autoSyncOff = !args?.enabled; return Promise.resolve(); }
|
||||
if (cmd === "lyrics_save_lines") {
|
||||
/* the Rust drops the empty lines and sorts by time — a half-tapped
|
||||
set is stored as the lines that HAVE one */
|
||||
const lines = (args?.lines || []).filter((l) => String(l?.text || "").trim())
|
||||
.map((l) => ({ time_ms: l.time_ms, text: l.text })).sort((a, b) => a.time_ms - b.time_ms);
|
||||
if (!lines.length) return Promise.reject(new Error("Nothing to save"));
|
||||
/* the Rust's payload_from does NOT carry the entry's auto-sync flag
|
||||
back from a write — false here, faithfully, so the walk notices if
|
||||
the store ever applies a save over the switch */
|
||||
return Promise.resolve({ status: "synced", source: "user", synced: lines, plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
|
||||
}
|
||||
/* the vocal-reduced copy: a render takes a beat, and it is a new file */
|
||||
if (cmd === "lyrics_prepare_instrumental") return new Promise((r) => setTimeout(() => r({ path: String(args?.path || "") + ".instrumental.wav", cached: false, durationSecs: 214 }), 400));
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
/* ── the tap-to-sync room: the song plays, the screen is the tap ───────── */
|
||||
.syncroom { position: fixed; inset: 0; z-index: 150; display: flex; flex-direction: column; background: radial-gradient(120% 70% at 20% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 58%), var(--bg-base); padding-top: var(--inset-top); padding-bottom: var(--inset-bottom); animation: viewer-in 160ms ease both; }
|
||||
.sy-head { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 2px; flex: none; }
|
||||
.sy-close { width: auto; min-width: var(--tap); padding: 0 10px; border-radius: var(--r-pill); }
|
||||
.sy-discard { font-size: 12.5px; font-weight: 800; color: #f15e6c; }
|
||||
.sy-who { flex: 1; min-width: 0; text-align: center; }
|
||||
.sy-who b { display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--accent-hover); }
|
||||
.sy-who i { display: block; font-style: normal; font-size: 13px; font-weight: 700; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sy-count { flex: none; min-width: 54px; text-align: right; font-size: 13px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.sy-warn { margin: 4px var(--pad) 0; padding: 9px 13px; border-radius: 10px; background: rgba(232, 163, 61, 0.14); color: #e8a33d; font-size: 12.5px; font-weight: 700; flex: none; }
|
||||
.sy-pad { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px var(--pad); text-align: center; }
|
||||
.sy-pad:active { background: rgba(255, 255, 255, 0.05); }
|
||||
.sy-pad:disabled { opacity: 0.5; }
|
||||
.sy-prev, .sy-next { display: block; width: 100%; min-height: 20px; font-size: 15px; font-weight: 650; line-height: 1.35; color: rgba(255, 255, 255, 0.3); overflow: hidden; }
|
||||
.sy-now { display: block; width: 100%; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.25; color: #fff; text-shadow: 0 0 26px color-mix(in srgb, var(--accent) 50%, transparent); }
|
||||
.sy-hint { display: block; margin-top: 6px; font-size: 12px; font-weight: 650; color: var(--text-muted); }
|
||||
.sy-mark { display: block; font-size: 13px; font-weight: 800; color: var(--accent-hover); font-variant-numeric: tabular-nums; }
|
||||
.sy-foot { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 16px; flex: none; }
|
||||
.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; }
|
||||
.sy-foot .iconbtn:disabled { opacity: 0.4; }
|
||||
.sy-save { flex: 1; }
|
||||
@@ -0,0 +1,13 @@
|
||||
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
||||
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
|
||||
"sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow,
|
||||
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
|
||||
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
|
||||
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
|
||||
"sync-resumed": (i) => i.sync && i.syCount === "1 / 3",
|
||||
"sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow),
|
||||
"sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast,
|
||||
/* the save's own payload carries no auto-sync flag: the switch kept its */
|
||||
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
||||
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
||||
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
||||
@@ -0,0 +1,25 @@
|
||||
/* auto-sync: the app's own correction turned off for this track, then
|
||||
back on — the re-read leaves the sheet up so the switch is seen */
|
||||
{ do: `__click('.lyr-tools', null)`, wait: 600 },
|
||||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 },
|
||||
/* off again, and left off: a save returns the document with that flag at
|
||||
its default, and the switch must not take it */
|
||||
{ do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 },
|
||||
/* the tap-to-sync room: taps become the timing, one undone, the room
|
||||
peeled by the back gesture and the set offered back, then saved */
|
||||
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
|
||||
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
||||
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
|
||||
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
|
||||
{ do: `history.back()`, wait: 600 },
|
||||
{ do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 },
|
||||
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
||||
{ do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 },
|
||||
{ do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 },
|
||||
/* the vocal-reduced copy, and back to the recording */
|
||||
{ do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 },
|
||||
{ do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 },
|
||||
{ do: `__click('.lyr-tools', null)`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 },
|
||||
@@ -0,0 +1,8 @@
|
||||
/* the walked album's track may genuinely have no words, so the room's
|
||||
own rows are conditional — the discard is not */
|
||||
"lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"),
|
||||
"lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"),
|
||||
"sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)),
|
||||
"sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)),
|
||||
"sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)),
|
||||
"sync-discarded": (i) => !i.sync && !i.toast,
|
||||
@@ -0,0 +1,11 @@
|
||||
/* auto-sync off and straight back on — the cache entry ends as it began */
|
||||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 },
|
||||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 },
|
||||
/* the tap room on the real clock: two taps, one undone, then discarded —
|
||||
nothing is written to Kyle's own lyrics for this track */
|
||||
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 },
|
||||
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
|
||||
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
|
||||
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
|
||||
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
|
||||
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
|
||||
@@ -0,0 +1,5 @@
|
||||
sync: !!document.querySelector('.syncroom'),
|
||||
syCount: (document.querySelector('.sy-count') || {}).textContent || '',
|
||||
syNow: (document.querySelector('.sy-now') || {}).textContent || '',
|
||||
lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
||||
trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(),
|
||||
@@ -0,0 +1,110 @@
|
||||
<script>
|
||||
/* The tap-to-sync room — the desktop's correction editor
|
||||
(LyricsSyncEditor.svelte) in the phone's idiom. The song plays and you
|
||||
tap once as each line begins; the taps become the timing. One line
|
||||
large, the line behind and the line ahead faint around it, and the
|
||||
whole middle of the screen is the tap. Saving writes the track's own
|
||||
document; lines never tapped are left out of it, and the button says
|
||||
how many are kept. */
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import { nav } from "./nav.svelte.js";
|
||||
import { player, toggle, seekSecs } from "./player.svelte.js";
|
||||
import { tapSync, saveTapSync, closeTapSync } from "./lyrics.svelte.js";
|
||||
import { mmss } from "./util.js";
|
||||
|
||||
/* the ✕ asks twice once there is work to lose; the back gesture keeps
|
||||
the taps instead (the lyrics sheet offers them back). The room is
|
||||
mounted for the life of the shell, so the question is put down when
|
||||
it closes — otherwise the next visit opens already asking. */
|
||||
let confirmDiscard = $state(false);
|
||||
$effect(() => { if (!nav.timing) confirmDiscard = false; });
|
||||
|
||||
/* position anchors land a couple of times a second, so a tap between
|
||||
them reads the last anchor plus the wall clock — the stamp carries
|
||||
the tap's own precision rather than the tick's. Plain lets, never
|
||||
read back inside the effect that writes them. */
|
||||
let lastPos = 0;
|
||||
let lastAt = 0;
|
||||
$effect(() => {
|
||||
void player.status;
|
||||
lastPos = player.position ?? 0;
|
||||
lastAt = performance.now();
|
||||
});
|
||||
function nowMs() {
|
||||
const base = player.status === "playing"
|
||||
? lastPos + (performance.now() - lastAt) / 1000
|
||||
: (player.position ?? 0);
|
||||
return Math.max(0, Math.round(base * 1000));
|
||||
}
|
||||
|
||||
const rows = $derived(tapSync.rows);
|
||||
const done = $derived(rows.filter((r) => r.time_ms != null).length);
|
||||
const at = $derived(Math.min(tapSync.cursor, rows.length));
|
||||
const playing = $derived(player.status === "playing" || player.status === "buffering");
|
||||
/* playback moved on: a stamp would land on another song's clock */
|
||||
const wrong = $derived(!!tapSync.identity && !!player.track && player.track.title !== tapSync.identity.title);
|
||||
|
||||
function stamp() {
|
||||
if (wrong || tapSync.saving || at >= rows.length) return;
|
||||
tapSync.rows[at].time_ms = nowMs();
|
||||
tapSync.cursor = at + 1;
|
||||
confirmDiscard = false;
|
||||
}
|
||||
function undo() {
|
||||
const last = tapSync.cursor - 1;
|
||||
if (last < 0) return;
|
||||
tapSync.rows[last].time_ms = null;
|
||||
tapSync.cursor = last;
|
||||
}
|
||||
function back5() { seekSecs(Math.max(0, (player.position ?? 0) - 5)); }
|
||||
function close() {
|
||||
if (done > 0 && !confirmDiscard) { confirmDiscard = true; return; }
|
||||
closeTapSync();
|
||||
}
|
||||
/* a stamp reads to a tenth — the precision a thumb actually has */
|
||||
function mark(ms) {
|
||||
if (ms == null) return "";
|
||||
return `${mmss(Math.floor(ms / 1000))}.${Math.floor((ms % 1000) / 100)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if nav.timing}
|
||||
<div class="syncroom">
|
||||
<div class="sy-head">
|
||||
<button class="iconbtn sy-close" onclick={close} aria-label={confirmDiscard ? "Discard the timing" : "Close"}>
|
||||
{#if confirmDiscard}<span class="sy-discard">Discard?</span>{:else}<Icon name="x" size={22} />{/if}
|
||||
</button>
|
||||
<span class="sy-who"><b>Tap in time</b><i>{tapSync.identity?.title ?? ""}</i></span>
|
||||
<span class="sy-count">{done} / {rows.length}</span>
|
||||
</div>
|
||||
|
||||
{#if wrong}
|
||||
<div class="sy-warn">Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.</div>
|
||||
{:else if tapSync.error}
|
||||
<div class="sy-warn">{tapSync.error}</div>
|
||||
{/if}
|
||||
|
||||
<button class="sy-pad" onclick={stamp} disabled={wrong}>
|
||||
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
|
||||
{#if at < rows.length}
|
||||
<span class="sy-now">{rows[at].text}</span>
|
||||
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
|
||||
<span class="sy-hint">Tap anywhere as the line begins</span>
|
||||
{:else}
|
||||
<span class="sy-now">That’s every line — save the timing.</span>
|
||||
<span class="sy-next"></span>
|
||||
<span class="sy-hint">Undo puts the last one back</span>
|
||||
{/if}
|
||||
<span class="sy-mark">{at > 0 ? mark(rows[at - 1].time_ms) : mmss(player.position)}</span>
|
||||
</button>
|
||||
|
||||
<div class="sy-foot">
|
||||
<button class="iconbtn sy-undo" onclick={undo} disabled={tapSync.cursor === 0} aria-label="Undo the last tap"><Icon name="history" size={22} /></button>
|
||||
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
|
||||
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
|
||||
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
|
||||
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : "Save timing")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** The folder door picks FIRST — the phone's own document-tree picker — so
|
||||
* the review only opens for a folder that was actually chosen. The tree has
|
||||
* to be on this phone's storage: the backend walks a path, and a cloud
|
||||
* provider's tree has none. Returns null when the picker was dismissed. */
|
||||
export async function pickImportFolder() {
|
||||
let picked;
|
||||
try { picked = await api.pickLibraryFolder(); }
|
||||
catch (e) { if (/cancelled/.test(String(e))) return null; throw e; }
|
||||
if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage.");
|
||||
return picked.path;
|
||||
}
|
||||
/** Stage a folder of audio files. The backend walks the folder and its disc
|
||||
* subfolders, orders by the files' own track numbers when every one carries
|
||||
* a distinct one and by natural filename order otherwise, and matches each
|
||||
* file against the library by path. Every row comes back local, so there is
|
||||
* nothing for the catalog pass to verify. */
|
||||
export async function startFolder(path) {
|
||||
const g = ++gen;
|
||||
Object.assign(importer, { door: "folder", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false });
|
||||
try {
|
||||
const draft = await api.playlistImportFolder(path);
|
||||
if (g !== gen) return;
|
||||
importer.busy = false;
|
||||
if (!draft) { importer.error = "Nothing came back from that folder."; return; }
|
||||
importer.draft = draft;
|
||||
importer.name = draft.name ?? "";
|
||||
} catch (e) {
|
||||
if (g === gen) { importer.error = String(e); importer.busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
spotify: "Spotify", "apple-music": "Apple Music", tidal: "TIDAL", deezer: "Deezer",
|
||||
links: "Pasted links",
|
||||
};
|
||||
|
||||
/* Mirrors the backend's detector closely enough to CHOOSE a door; the
|
||||
backend decides for real. A paste holding even one track link takes the
|
||||
link path, so a list copied out of Spotify never parses as track names. */
|
||||
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
|
||||
@@ -0,0 +1,39 @@
|
||||
if (cmd === "playlist_import_track_links") {
|
||||
const links = String(args?.text || "").split(/\r?\n/).map((l) => l.trim())
|
||||
.filter((l) => /^https?:\/\//.test(l) || /^spotify:track:/.test(l));
|
||||
if (!links.length) return Promise.reject(new Error("Nothing to import in that paste."));
|
||||
/* one row per link, in the paste's order — the lines a real lookup
|
||||
would have read off each track's own public page. A page each is
|
||||
slow, and the pace here is slow on purpose: the walk's still takes
|
||||
long enough to miss a wait card that closes inside a second. */
|
||||
const named = ["Aphex Twin - Track 1", "Daft Punk - One More Time", "Bicep - Glue", "Four Tet - Track 2"];
|
||||
const entries = impParse(links.map((_, i) => named[i % named.length]).join("\n"));
|
||||
return new Promise((r) => {
|
||||
let done = 0;
|
||||
const tick = setInterval(() => {
|
||||
done++;
|
||||
mockEmit("playlist:link-progress", { done, total: links.length, waiting: false });
|
||||
if (done >= links.length) {
|
||||
clearInterval(tick);
|
||||
setTimeout(() => r(impDraft(entries, args?.name || "Pasted track links", "links", args?.name ?? null, {
|
||||
warnings: [entries.length + " track" + (entries.length === 1 ? "" : "s") + " read from " + links.length + " pasted link" + (links.length === 1 ? "" : "s") + "."],
|
||||
})), 250);
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
}
|
||||
if (cmd === "playlist_import_folder") {
|
||||
const root = String(args?.path || "");
|
||||
if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */
|
||||
const name = root.split(/[\\/]/).filter(Boolean).pop() || "Folder";
|
||||
/* four files off a record the library already holds, as the backend's
|
||||
walk hands them over: every row local, matched by path */
|
||||
const own = albums[0];
|
||||
const entries = tracksFor(own.id).slice(0, 4).map((t, i) => ({
|
||||
ordinal: i, title: t.title, artist: own.artist, album: own.title,
|
||||
duration_secs: t.duration_secs, start_ms: null, raw: t.path,
|
||||
resolution: "local", matched_by: "path", path: t.path, art_path: own.art_path,
|
||||
track_id: null, art_url: null, isrc: null,
|
||||
}));
|
||||
return new Promise((r) => setTimeout(() => r(impDraft(entries, name, "folder", name)), 500));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"import-links-wait": (i) => i.impWait && /pasted links/.test(i.impWaitLine),
|
||||
"import-links-review": (i) => i.imp === 3 && /Pasted links/.test(i.impFmt) && /read from 3 pasted links/.test(i.impWarn) && /Create playlist \(3 tracks\)/.test(i.impBtn),
|
||||
"import-folder-review": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp === 4 && i.impTags === "file|file|file|file" && /4 in library/.test(i.impCounts) && /Create playlist \(4 tracks\)/.test(i.impBtn),
|
||||
"import-folder-created": (i) => i.title.startsWith("Music") && i.plrows === 4,
|
||||
@@ -0,0 +1,13 @@
|
||||
/* pasted TRACK links: the paste door reads each one off its own page —
|
||||
the wait line names the door, the warning counts the links */
|
||||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/aaaaaaaaaaaaaaaaaaaaaa\\nhttps://open.spotify.com/track/bbbbbbbbbbbbbbbbbbbbbb\\nhttps://open.spotify.com/track/cccccccccccccccccccccc'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 500 },
|
||||
{ name: "import-links-review", wait: 2600 },
|
||||
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
|
||||
/* the folder door: the phone's own folder picker, then the backend's walk */
|
||||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-review", wait: 2200 },
|
||||
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-folder-created", wait: 1500 },
|
||||
{ do: `history.back()`, wait: 900 },
|
||||
@@ -0,0 +1,8 @@
|
||||
/* the real core can answer one track link faster than the probe lands,
|
||||
so the wait card is allowed to be gone already — but then the review
|
||||
it turned into has to be there. Either way the door did something. */
|
||||
"import-links-wait": (i) => i.title.startsWith("Import") && (i.impWait ? /pasted links/.test(i.impWaitLine) : i.imp > 0 || i.impErr),
|
||||
"import-links-review": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
|
||||
"import-folder-real": (i) => i.title.startsWith("Import") && /Folder/.test(i.impFmt) && i.imp > 0,
|
||||
"import-folder-picker": (i) => !i.half.includes("up") && !i.toast,
|
||||
"import-folder-cancelled": (i) => i.title.startsWith("Your library") && !i.half.includes("up") && !i.toast,
|
||||
@@ -0,0 +1,17 @@
|
||||
/* the pasted-links door on the real core: one track link, and the wait
|
||||
line says which door read the paste; the review is left as it came */
|
||||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/track/0DiWJLRWCDgbxTYvvVjHOP'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-links-wait", wait: 700 },
|
||||
{ name: "import-links-review", wait: 9000 },
|
||||
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
|
||||
/* the folder door on the real core, one record's own folder — the
|
||||
argument the command grew, walked for real; nothing is created */
|
||||
{ do: `(async function () { var a = __ss.lib.albums[0]; var ts = await __ss.api.albumTracks(a.id); var f = ts[0].path; var cut = Math.max(f.lastIndexOf('/'), f.lastIndexOf(String.fromCharCode(92))); __ss.push('import', {}); __ss.imp.startFolder(f.slice(0, cut)); return f.slice(0, cut); })()`, name: "import-folder-real", wait: 7000 },
|
||||
{ do: `__click('.imp-foot .btn-ghost', null)`, wait: 900 },
|
||||
/* the picker itself is a system sheet the walk cannot tap: open it,
|
||||
dismiss it with BACK, and check the cancel path leaves nothing */
|
||||
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Folder of audio files')`, name: "import-folder-picker", wait: 2600 },
|
||||
{ adb: ["shell", "input", "keyevent", "4"], name: "import-folder-cancelled", wait: 2000 },
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Popular on its own — the desktop's fast lane (`catalog_artist_popular`).
|
||||
* It waits on nothing but the linked MBID and one Last.fm call, so the
|
||||
* section can paint while the full page is still being built; the page's
|
||||
* own list wins once it lands (the same order, from the same inputs), and
|
||||
* this one stays for an artist whose page came back with no Popular. */
|
||||
export function loadPopular(id) {
|
||||
if (id == null || catalog.popular[id]) return;
|
||||
catalog.popular[id] = { status: "loading", tracks: [] };
|
||||
api.catalogArtistPopular(id).then(
|
||||
(tracks) => { catalog.popular[id] = { status: "ready", tracks: tracks ?? [] }; },
|
||||
() => { catalog.popular[id] = { status: "error", tracks: [] }; },
|
||||
);
|
||||
}
|
||||
|
||||
/** A portrait for a byline — the same identity rule, search only. */
|
||||
@@ -0,0 +1,7 @@
|
||||
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 },
|
||||
/* the artist's photo: the page's ⋯ → a picture from this phone becomes
|
||||
the hero, the page is asked again, then the catalog's own come back */
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Choose hero photo')`, name: "artist-hero-set", wait: 1400 },
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more-custom", wait: 800 },
|
||||
{ do: `__click('.halfsheet .row', 'Remove custom hero')`, name: "artist-hero-cleared", wait: 1400 },
|
||||
@@ -0,0 +1,5 @@
|
||||
{ name: "artist", wait: 4500 },
|
||||
/* the page's ⋯ on the real core: the hero door is there and knows the
|
||||
artist's id — it opens the system picker, so the walk only looks */
|
||||
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 900 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
|
||||
@@ -0,0 +1,3 @@
|
||||
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
|
||||
/* the desktop's portrait refresh, beside the covers' */
|
||||
{ do: `__click('.screen .row', 'Refresh artist portraits')`, name: "you-portraits", wait: 900 },
|
||||
@@ -0,0 +1,51 @@
|
||||
/** The rows a record's own artist anchor found no count for — a feature's
|
||||
* guest, a compilation's stranger — asked by each row's OWN Deezer artist
|
||||
* id, the rule the desktop's search results follow. A row the backend has
|
||||
* no count for keeps nothing: unknown is not a zero. */
|
||||
export function loadTrackListeners(albumId, tracks) {
|
||||
if (albumId == null || catalog.listeners[albumId]) return;
|
||||
const want = (tracks || []).filter((t) => t.listeners == null && t.artist_id != null);
|
||||
if (!want.length) return;
|
||||
catalog.listeners[albumId] = { status: "loading", counts: {} };
|
||||
api.catalogTrackListeners(want.map((t) => ({ artistId: t.artist_id, title: t.title }))).then(
|
||||
(counts) => {
|
||||
const out = {};
|
||||
(counts || []).forEach((n, i) => { if (n != null && want[i]) out[want[i].id] = n; });
|
||||
catalog.listeners[albumId] = { status: "ready", counts: out };
|
||||
},
|
||||
() => { catalog.listeners[albumId] = { status: "error", counts: {} }; },
|
||||
);
|
||||
}
|
||||
|
||||
/** Forget an artist's page and ask for it again — the desktop's
|
||||
* `invalidateCatalogArtist`, for a hero that has just changed. The seed is
|
||||
* kept, so the identity search does not run a second time. */
|
||||
export function forgetArtist(name) {
|
||||
const k = artistKey(name);
|
||||
const cur = catalog.artists[k];
|
||||
if (!cur) return;
|
||||
delete catalog.artists[k];
|
||||
lookupArtist(name, cur.seed ?? null);
|
||||
}
|
||||
|
||||
/** A photo from this phone as an artist's hero — the desktop's Choose hero,
|
||||
* through the phone's own image picker. The page is asked again, so the
|
||||
* banner, the ground and the accent all follow. */
|
||||
export async function heroFromPhoto(id, name) {
|
||||
let r;
|
||||
try { r = await api.pickImage(); } catch (e) { if (/cancelled/.test(String(e))) return false; throw e; }
|
||||
if (!r?.path) return false;
|
||||
const hero = await api.setArtistHero(id, r.path);
|
||||
forgetArtist(name);
|
||||
note(`Hero set · ${hero.width}×${hero.height}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The chosen hero removed — the catalog's own photos come back. */
|
||||
export async function heroClear(id, name) {
|
||||
const removed = await api.clearArtistHero(id);
|
||||
forgetArtist(name);
|
||||
note(removed ? "The catalog's own photos are back" : "No custom hero here");
|
||||
}
|
||||
|
||||
export function lookupAlbum(id) {
|
||||
@@ -0,0 +1,10 @@
|
||||
/* the desktop Settings row: a portrait for every artist already here */
|
||||
async function refreshPortraits() {
|
||||
if (refreshingArtists) return;
|
||||
refreshingArtists = true;
|
||||
try { const n = await api.refreshArtistArt(); note(`${n ?? 0} portrait${n === 1 ? "" : "s"} refreshed`); }
|
||||
catch (e) { player.error = String(e); }
|
||||
finally { refreshingArtists = false; }
|
||||
}
|
||||
const ROWS = [
|
||||
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
/* Diagnostics — the desktop's debug overlay (Ctrl+Shift+D) as a screen,
|
||||
on the dev flavour only: what this process costs the phone, what the
|
||||
network is moving, the track playing and its loudness correction, the
|
||||
tracks the pre-buffer is fetching behind it, and how big the play
|
||||
history has grown. Polled once a second while the screen is open and
|
||||
never otherwise, the way the overlay does it. The GPU counters are
|
||||
Windows PDH and the private commit is a Win32 call, so neither row is
|
||||
here — on the phone they would read nothing at all. */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { fmtBytes, basename } from "../util.js";
|
||||
|
||||
let stats = $state(null);
|
||||
let plays = $state(null);
|
||||
let failed = $state("");
|
||||
|
||||
/* nothing reactive is READ in here: the effect runs once when the screen
|
||||
mounts and its cleanup when the screen leaves. An effect that reads
|
||||
what it writes takes its whole subtree down with it. */
|
||||
$effect(() => {
|
||||
let live = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = await api.debugStats();
|
||||
if (live) { stats = s; failed = ""; }
|
||||
} catch (e) {
|
||||
if (live) failed = String(e);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
api.playRowCount().then((n) => { if (live) plays = n; }, () => {});
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => { live = false; clearInterval(id); };
|
||||
});
|
||||
|
||||
const pct = (v) => (v == null ? "—" : v.toFixed(1) + "%");
|
||||
const size = (n) => (n ? fmtBytes(n) : "—");
|
||||
const rate = (n) => (n ? fmtBytes(n) + "/s" : "—");
|
||||
const db = (v) => (v == null ? "—" : (v > 0 ? "+" : "") + v.toFixed(1) + " dB");
|
||||
const upfor = (s) => {
|
||||
if (s == null) return "—";
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
return h ? h + " hr " + m + " min" : m ? m + " min" : s + " s";
|
||||
};
|
||||
const share = (p) => (p.total ? Math.max(0.02, Math.min(1, p.received / p.total)) : 0);
|
||||
</script>
|
||||
|
||||
<div class="phead"><div class="row"><h1>Diagnostics</h1></div></div>
|
||||
<div class="sec-note">The desktop's debug overlay, on the dev flavour. Polled once a second while this screen is open.</div>
|
||||
|
||||
{#if failed}
|
||||
<div class="stub">
|
||||
<b>No diagnostics.</b>
|
||||
{failed}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-sec">This process</div>
|
||||
<div class="group diag">
|
||||
<div class="kv"><span class="k">App CPU</span><span class="v">{pct(stats?.cpuProcess)}</span></div>
|
||||
<div class="kv"><span class="k">System CPU</span><span class="v">{pct(stats?.cpuSystem)}</span></div>
|
||||
<div class="kv"><span class="k">Memory</span><span class="v">{size(stats?.memWorkingSet)}</span></div>
|
||||
<div class="kv"><span class="k">System memory</span><span class="v dim">{size(stats?.memSystemUsed)} of {size(stats?.memSystemTotal)}</span></div>
|
||||
<div class="kv"><span class="k">Processes</span><span class="v">{stats?.processCount ?? "—"}</span></div>
|
||||
<div class="kv"><span class="k">Up</span><span class="v">{upfor(stats?.uptimeSecs)}</span></div>
|
||||
</div>
|
||||
<div class="sec-note">Processes counts this app's own tree. Android runs the WebView in a system process of its own, so it is outside these numbers; on the desktop the whole WebView2 family is inside them.</div>
|
||||
|
||||
<div class="sheet-sec">Network</div>
|
||||
<div class="group diag">
|
||||
<div class="kv"><span class="k">Down</span><span class="v">{rate(stats?.netRxBps)}</span></div>
|
||||
<div class="kv"><span class="k">Up</span><span class="v">{rate(stats?.netTxBps)}</span></div>
|
||||
</div>
|
||||
<div class="sec-note">Every interface on the phone, not this app alone.</div>
|
||||
|
||||
<div class="sheet-sec">The track playing</div>
|
||||
<div class="group diag">
|
||||
<div class="kv"><span class="k">Stream</span><span class="v">{!stats ? "—" : stats.dlTotal ? size(stats.dlReceived) + " of " + size(stats.dlTotal) + (stats.dlDone ? " · done" : "") : "nothing streaming"}</span></div>
|
||||
<div class="kv"><span class="k">Normalization</span><span class="v">{!stats ? "—" : stats.normalizationEnabled ? "on · " + (stats.targetLufs ?? -14) + " LUFS" : "off"}</span></div>
|
||||
<div class="kv"><span class="k">Measured</span><span class="v dim">{stats?.loudness?.lufs != null ? stats.loudness.lufs.toFixed(1) + " LUFS" + (stats.loudness.analyzing ? " · measuring" : "") : "—"}</span></div>
|
||||
<div class="kv"><span class="k">Gain</span><span class="v">{db(stats?.normNowDb)}{stats && stats.normNowDb != null && stats.normNowDb !== stats.normTargetDb ? " → " + db(stats.normTargetDb) : ""}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-sec">On deck</div>
|
||||
{#if stats?.preloadDl?.length}
|
||||
<div class="group diag-deck">
|
||||
{#each stats.preloadDl as p (p.username + p.path)}
|
||||
<div class="row">
|
||||
<span class="lead"><Icon name="download" size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{basename(p.path)}</span>
|
||||
<span class="m">{p.username} · {p.state}{p.place != null ? " · queue " + p.place : ""}{p.speedBps ? " · " + fmtBytes(p.speedBps) + "/s" : ""}</span>
|
||||
{#if p.total}<span class="lbar"><i style="transform: scaleX({share(p).toFixed(3)})"></i><em>{fmtBytes(p.received)}</em></span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sec-note">Nothing pre-buffering. The player readies the next three tracks while one plays.</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-sec">Library</div>
|
||||
<div class="group diag">
|
||||
<div class="kv"><span class="k">Play rows</span><span class="v">{plays != null ? plays.toLocaleString() : "—"}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="endpad"></div>
|
||||
@@ -0,0 +1,5 @@
|
||||
record the queue holds; beside them, "Copy to a folder…" writes that same
|
||||
cached stream into a folder picked on this phone, as the plain file it is
|
||||
(`copy_stream_to_folder` — no re-download, no tagging, no library row; a
|
||||
track that has not finished streaming is counted back, never fetched).
|
||||
A moment after boot the phone warms the library's thumbnails the
|
||||
@@ -0,0 +1,22 @@
|
||||
Sharing carries one thing the desktop never exposed at all: the listening
|
||||
port (`listenPort`), what other peers dial when they download from you.
|
||||
Unset means the client's own 50300 (`DEFAULT_LISTEN_PORT`; bind walks
|
||||
forward a few ports if it is taken); the app asks the router to forward it
|
||||
over UPnP and falls back to the server relaying the connection, and a
|
||||
change applies on the next connect — on mobile data the carrier's NAT
|
||||
blocks incoming connections whatever the number is. `set_settings` only
|
||||
assigns a port that is Some, so it can be changed but never taken back to
|
||||
unset: the phone walk saves a spare number and then 50300, which is the
|
||||
port an unset config already used.
|
||||
|
||||
The dev flavour gets one more row under This phone: Diagnostics
|
||||
(`screens/Diagnostics.svelte`), the desktop's Ctrl+Shift+D overlay as a
|
||||
screen — process and system CPU, memory, the network rates, the track
|
||||
playing with its loudness correction, the tracks the pre-buffer is
|
||||
fetching, and how big the play history has grown (`debug_stats`,
|
||||
`play_row_count`), polled once a second while it is open and never
|
||||
otherwise. `devBuild` in `util.js` is the gate: Vite's own DEV flag, true
|
||||
only for the build that loads from the dev server over the cable, so the
|
||||
embedded build the phone keeps has no row to tap. The GPU counters are
|
||||
Windows PDH and the private commit is a Win32 call, so neither is on the
|
||||
screen — off Windows they report None and 0.
|
||||
@@ -0,0 +1,18 @@
|
||||
/* the desktop's copy_stream_to_folder: the cached stream written into a
|
||||
folder picked on this phone, as the file it is — no re-download, no
|
||||
tagging, no library row. Nothing leaves the phone, so the Wi-Fi guard
|
||||
has no say here. A track that has not finished streaming is counted
|
||||
back as missing, never fetched. */
|
||||
async function copyToFolder(entries) {
|
||||
const files = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path }));
|
||||
if (!files.length) return;
|
||||
try {
|
||||
const r = await api.pickLibraryFolder();
|
||||
if (!r?.path) { player.error = "That folder has no path the phone can write to — pick one on this phone's storage."; return; }
|
||||
const [saved, missing] = await api.copyStreamToFolder(files, r.path);
|
||||
note(saved ? saved + " file" + (saved === 1 ? "" : "s") + " copied" + (missing ? " · " + missing + " not cached yet" : "")
|
||||
: "Not cached yet — play it through first");
|
||||
} catch (e) {
|
||||
if (!/cancelled/.test(String(e))) oops(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && i.promptVal === "" && /^50300 \(the default\)/.test(i.port),
|
||||
"you-port": (i) => !i.half.includes("up") && /^50555 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||||
"you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50555 /.test(i.port),
|
||||
"diag": (i) => i.title.startsWith("Diagnostics") && i.diagRows >= 12 && /%$/.test(i.diagCpu) && /4,?210/.test(i.diagPlays) && i.diagDeck === 1,
|
||||
"diag-back": (i) => i.title.startsWith("You"),
|
||||
@@ -0,0 +1,13 @@
|
||||
/* the listening port: typed in, saved, read back off the config — then
|
||||
a port below 1024 refused and the saved one left standing */
|
||||
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 600 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50555'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 700 },
|
||||
{ do: `__click('.screen .row', 'Listening port')`, wait: 500 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 600 },
|
||||
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
|
||||
/* the dev flavour's diagnostics — the desktop's Ctrl+Shift+D overlay as
|
||||
a screen; a normal build has no row here to tap */
|
||||
{ do: `__click('.screen .row', 'Diagnostics')`, name: "diag", wait: 1500 },
|
||||
{ do: `history.back()`, name: "diag-back", wait: 800 },
|
||||
@@ -0,0 +1,28 @@
|
||||
if (cmd === "copy_stream_to_folder") {
|
||||
/* the real command copies only what the stream cache already holds */
|
||||
let saved = 0, missing = 0;
|
||||
for (const f of args?.files || []) { if (cacheReady.has(f.path)) saved++; else missing++; }
|
||||
return Promise.resolve([saved, missing]);
|
||||
}
|
||||
if (cmd === "play_row_count") return Promise.resolve(4210);
|
||||
if (cmd === "debug_stats") {
|
||||
/* the overlay's shape, camelCase as serde renames it; the numbers
|
||||
wander a little so the screen is seen to be live */
|
||||
const t = Date.now() / 1000;
|
||||
return Promise.resolve({
|
||||
cpuProcess: 7.5 + Math.sin(t), cpuSystem: 22 + Math.cos(t) * 3,
|
||||
gpuPercent: null, gpuAppPercent: null,
|
||||
memWorkingSet: 431_000_000, memPrivate: 0,
|
||||
memSystemUsed: 5_400_000_000, memSystemTotal: 7_900_000_000,
|
||||
netRxBps: 1_250_000, netTxBps: 96_000,
|
||||
processCount: 3, uptimeSecs: 742,
|
||||
dlReceived: xfer ? Math.round(xfer.received) : null,
|
||||
dlTotal: xfer ? xfer.total : null,
|
||||
dlDone: xfer ? xfer.received >= xfer.total : null,
|
||||
normalizationEnabled: mockSettings.volume_normalization !== false, targetLufs: -14,
|
||||
loudness: { lufs: -9.8, peak: 0.98, gainDb: -4.2, cached: true, analyzing: false, source: "cache", headStartDb: 0, prefixGainDb: null },
|
||||
mainFadeGain: 1, normNowDb: -4.2, normTargetDb: -4.2,
|
||||
previewVolume: null, previewNormDb: null,
|
||||
preloadDl: [{ username: "dusty_crates", path: "Music\\Daft Punk\\Discovery\\02 Aerodynamic.flac", state: "transferring", place: null, received: 5_000_000, total: 12_000_000, speedBps: 850_000 }],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* the same cache, copied out as a plain file into a picked folder —
|
||||
nothing downloaded again, no library row */
|
||||
{ do: `__click('.halfsheet .row', 'Copy to a folder')`, name: "np-copied", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, wait: 700 },
|
||||
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
|
||||
@@ -0,0 +1,3 @@
|
||||
"you-port-prompt": (i) => i.half.includes("up") && /Listening port/.test(i.sheetTitle) && !i.toast,
|
||||
"you-port": (i) => !i.half.includes("up") && /^50310 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||||
"you-port-restored": (i) => !i.half.includes("up") && /^50300 /.test(i.port) && !/the default/.test(i.port) && !i.toast,
|
||||
@@ -0,0 +1,9 @@
|
||||
/* the listening port on the real core, camelCase for real: a spare
|
||||
number, then 50300 — the client's own default, so the phone ends the
|
||||
walk on exactly the port an unset config would already have used */
|
||||
{ do: `__click('.screen .row', 'Listening port')`, name: "you-port-prompt", wait: 900 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50310'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port", wait: 1200 },
|
||||
{ do: `__click('.screen .row', 'Listening port')`, wait: 800 },
|
||||
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '50300'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
||||
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-restored", wait: 1200 },
|
||||
@@ -0,0 +1,5 @@
|
||||
port: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Listening port/.test(x.textContent); }); var m = r && r.querySelector('.m'); return m ? m.textContent.trim() : ''; })(),
|
||||
diagRows: document.querySelectorAll('.group.diag .kv').length,
|
||||
diagCpu: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /App CPU/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
|
||||
diagPlays: (function () { var r = [].slice.call(document.querySelectorAll('.group.diag .kv')).find(function (x) { return /Play rows/.test(x.textContent); }); var v = r && r.querySelector('.v'); return v ? v.textContent.trim() : ''; })(),
|
||||
diagDeck: document.querySelectorAll('.group.diag-deck .row').length,
|
||||
@@ -0,0 +1,19 @@
|
||||
/* the Soulseek listener: the port other peers dial when they download
|
||||
from you. Unset means the client's own 50300, read at connect — bind
|
||||
walks forward a few ports if it is taken. `set_settings` only assigns
|
||||
a port that is Some, so a number typed here can be changed but never
|
||||
taken back to unset. */
|
||||
const DEFAULT_LISTEN_PORT = 50300;
|
||||
function portPrompt() {
|
||||
openHalf("prompt", {
|
||||
title: "Listening port",
|
||||
value: cfg?.listen_port != null ? String(cfg.listen_port) : "",
|
||||
placeholder: String(DEFAULT_LISTEN_PORT),
|
||||
ok: "Save", numeric: true,
|
||||
onsubmit: (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1024 || n > 65535) { player.error = "A port is a number between 1024 and 65535."; return; }
|
||||
void saveCfg({ listenPort: n });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* ── the phone turned sideways ────────────────────────────────────────────
|
||||
The activity declares no screenOrientation and handles orientation and
|
||||
screenSize itself, so a rotation is a RESIZE: no state is lost and the
|
||||
layout is the only thing that has to answer. Two queries do it. The player
|
||||
sheet is the surface whose portrait shape cannot survive a wide viewport —
|
||||
its artwork is a square sized by WIDTH, so at 915x411 the cover came out
|
||||
879px tall and the title, the lane, the transport and the face buttons all
|
||||
sat below the bottom of a sheet that does not scroll. That is aspect, not
|
||||
height, so its rule keys off orientation alone and a tablet gets it too;
|
||||
everything under it is a phone-sized query.
|
||||
|
||||
These rules are appended, and the order is load-bearing: the side-inset
|
||||
longhands below have to come after .gbar's padding shorthand, .mini's
|
||||
left/right and .toast's left/right to win at equal specificity. */
|
||||
|
||||
/* the side insets: the cutout and the gesture pill, from the edges */
|
||||
.gbar { padding-left: calc(6px + var(--inset-left)); padding-right: calc(8px + var(--inset-right)); }
|
||||
.nav { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.mini { left: calc(8px + var(--inset-left)); right: calc(8px + var(--inset-right)); }
|
||||
.screen { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.np { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.halfsheet { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.syncroom { padding-left: var(--inset-left); padding-right: var(--inset-right); }
|
||||
.toast { left: calc(12px + var(--inset-left)); right: calc(12px + var(--inset-right)); }
|
||||
|
||||
/* ── the player sheet, beside itself ─────────────────────────────────── */
|
||||
@media (orientation: landscape) {
|
||||
.np {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 46%) minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
|
||||
grid-template-areas:
|
||||
"head head"
|
||||
"left slack"
|
||||
"left meta"
|
||||
"left scrub"
|
||||
"left trans"
|
||||
"left foot";
|
||||
column-gap: 6px;
|
||||
/* insurance for a viewport shorter than the controls themselves —
|
||||
the keyboard up on a small phone — where the sheet would clip */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.np-head { grid-area: head; }
|
||||
.np-art { grid-area: left; padding: 10px 4px 10px var(--pad); }
|
||||
/* the square is sized by whichever is smaller, the column or the height */
|
||||
.np-frame { width: min(100%, calc(100vh - var(--inset-top) - var(--inset-bottom) - 92px)); }
|
||||
.np-spacer { grid-area: slack; padding: 10px var(--pad) 0; justify-content: center; }
|
||||
.np-face { grid-area: left; }
|
||||
.np-meta { grid-area: meta; padding-top: 2px; }
|
||||
.np-scrub { grid-area: scrub; padding-top: 8px; }
|
||||
.np-transport { grid-area: trans; padding-top: 10px; justify-self: center; width: min(100%, 430px); }
|
||||
.np-foot { grid-area: foot; padding-top: 8px; }
|
||||
/* a face is up: it takes the artwork's column and the thumbnail goes —
|
||||
the record is already the ground behind the whole sheet */
|
||||
.np.small { grid-template-columns: minmax(0, 1fr) minmax(0, 430px); }
|
||||
.np.small .np-art { display: none; }
|
||||
}
|
||||
|
||||
/* ── a phone turned sideways: 411 pixels of height to spend ───────────── */
|
||||
@media (orientation: landscape) and (max-height: 560px) {
|
||||
/* the nav comes down to Android's own 48dp minimum and the mini bar to a
|
||||
60dp row over the same lane strip: 22 pixels back, 46% of the height as
|
||||
chrome down to 40% */
|
||||
:root { --nav-h: 48px; --mini-h: 60px; }
|
||||
.nav button .gl { height: 22px; }
|
||||
.nav button .lb { font-size: 9.5px; }
|
||||
.nav button .pill { top: 3px; height: 24px; }
|
||||
.mini { padding-bottom: 24px; }
|
||||
.mini .art { width: 34px; height: 34px; }
|
||||
.mini .mini-lane { bottom: 5px; }
|
||||
|
||||
/* the hero stops being a portrait and becomes the banner the desktop's
|
||||
hero has always been */
|
||||
.hero { height: 190px; }
|
||||
.hero-name { font-size: 27px; }
|
||||
.hero-logo { max-height: 64px; }
|
||||
|
||||
/* the record head puts the cover beside the title, again the desktop's
|
||||
own shape; .pl needs naming because its own rule is more specific */
|
||||
.rec-head { flex-direction: row; align-items: center; gap: 22px; padding: calc(var(--bar-h) + 12px) var(--pad) 4px; }
|
||||
.rec-head .rec-cover, .rec-head.pl .rec-cover { width: 34%; max-width: 240px; }
|
||||
.rec-meta { text-align: left; }
|
||||
.rec-title { margin-top: 0; }
|
||||
.rec-chips { justify-content: flex-start; }
|
||||
.rec-acts { justify-content: flex-start; padding: 14px 0 4px; }
|
||||
|
||||
/* 76% of 411 is 312; a half sheet may take all but a strip */
|
||||
.halfsheet { max-height: calc(100% - 36px); }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
The activity declares no `screenOrientation` and handles `orientation` and
|
||||
`screenSize` itself, so turning the phone over is a resize and nothing else —
|
||||
the audio thread, the stack and the screen's scroll all survive it, and the
|
||||
layout is the only thing that has to answer. Two media queries do that. The
|
||||
player sheet is the one surface whose portrait shape cannot survive a wide
|
||||
viewport: its artwork is a square sized by WIDTH, so at 915×411 the cover came
|
||||
out 879px tall and the title, the lane, the transport and the face buttons sat
|
||||
below the bottom of a sheet that does not scroll — a corner of the cover and no
|
||||
way to pause. Sideways it is two columns: the head across the top, the cover
|
||||
down the left sized by HEIGHT, and the track, the scrubber, the transport and
|
||||
the faces stacked on the right. With lyrics or the queue up, the face takes the
|
||||
cover's column and the thumbnail goes — the record is already the ground behind
|
||||
the whole sheet. That is an aspect problem rather than a height one, so the rule
|
||||
keys off `orientation: landscape` alone and a tablet gets the same two columns.
|
||||
|
||||
The rest is a phone-sized query (`max-height: 560px`). The nav comes down to
|
||||
Android's own 48dp minimum and the mini bar to a 60dp row over the same lane
|
||||
strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46%
|
||||
of the height, it is 40%. The artist hero stops being a 300px portrait, which
|
||||
covered its own Play button with the mini bar, and becomes a 190px banner: what
|
||||
the desktop's hero has always been. The record head puts the cover beside the
|
||||
title rather than above it, again the desktop's own shape, so Play is in view
|
||||
without a scroll. A half sheet may take all but 36px of the height instead of
|
||||
76% of it. Everything else — the grids, the rails, the artwork viewer, the
|
||||
tap-to-sync room, the chat composer — already answered to width and was left
|
||||
alone. `viewport-fit=cover` means the cutout and the gesture pill come in from
|
||||
the SIDES in landscape, so the shell reads `--inset-left` / `--inset-right`
|
||||
beside the top and bottom ones now. Those rules are appended at the end of
|
||||
`shell.css` on purpose: they have to come after `.gbar`'s padding, `.mini`'s
|
||||
and `.toast`'s left/right to win at equal specificity.
|
||||
@@ -0,0 +1,19 @@
|
||||
/* sideways: the chrome budget, then every surface that was clipped. The
|
||||
before-value of each is in the rig's now.json — 46% of the height as
|
||||
chrome, a 879px cover with the transport 687px below the fold, a
|
||||
lyrics face collapsed to nothing, a 300px hero, and the record's own
|
||||
Play button off the bottom of a 411px screen. */
|
||||
landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title),
|
||||
"landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0,
|
||||
"landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0,
|
||||
/* the queue face by name, not by its rows: the track this walk is on is
|
||||
the LAST queueable row of Late night drive (the missing and the
|
||||
unresolved ones never reach the queue), so there is nothing up next
|
||||
and `qrows` is 0 here — the assertion is that the queue's own face has
|
||||
the cover's column, not that something is after this song */
|
||||
"landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0,
|
||||
"landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn,
|
||||
"landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0,
|
||||
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
|
||||
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
|
||||
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
|
||||
@@ -0,0 +1,21 @@
|
||||
vp: innerWidth + 'x' + innerHeight,
|
||||
/* the sheet's controls: any one of them off the viewport is the landscape bug */
|
||||
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
|
||||
/* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */
|
||||
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
|
||||
faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height),
|
||||
/* WHICH face: the queue's own, up whether or not anything is after this one */
|
||||
qFace: !!document.querySelector('.sheet.up .q-face'),
|
||||
/* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */
|
||||
halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height),
|
||||
halfIn: inside(box('.halfsheet.up')),
|
||||
hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(),
|
||||
hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(),
|
||||
heroH: Math.round((box('.screen .hero') || { height: 0 }).height),
|
||||
/* what a page offers first: Play on an artist hero, Play on a record head */
|
||||
actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')),
|
||||
recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(),
|
||||
/* the glass bar above plus the floating bars below, as a share of the screen */
|
||||
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
|
||||
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
|
||||
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
|
||||
@@ -0,0 +1,32 @@
|
||||
/* ── the phone turned over. A resize, so the app keeps playing and keeps
|
||||
its stack; what changes is the layout, and every one of these asserts
|
||||
something that was clipped or unreachable at 915x411 before. ────── */
|
||||
{ view: [915, 411], name: "landscape", wait: 900 },
|
||||
{ do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 },
|
||||
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
|
||||
{ do: `__ss.closeSheet()`, wait: 800 },
|
||||
/* a record sideways: the cover beside the title, Play in view without a
|
||||
scroll, and a long sheet over it — the track's, 473px of rows and
|
||||
facts — capped taller than portrait's 76% and still scrolling to its
|
||||
last row. (Home's own overflow sheet is 212px tall at either cap and
|
||||
would have proved nothing.) */
|
||||
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
|
||||
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
||||
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 },
|
||||
{ do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 },
|
||||
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
|
||||
/* and the artist: the hero a banner rather than 300px of a 411px screen,
|
||||
so it stops covering its own Play button with the mini bar */
|
||||
{ do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 },
|
||||
{ do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 },
|
||||
/* the side insets, faked: sideways the cutout and the gesture pill come
|
||||
in from the edges, and env() cannot be emulated here — the plumbing can */
|
||||
{ do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 },
|
||||
{ do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 },
|
||||
/* and back, which is also the override reset: portrait must be exactly
|
||||
what it was before any of this */
|
||||
{ view: [411, 914], wait: 800 },
|
||||
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
|
||||
{ do: `__ss.closeSheet()`, wait: 600 },
|
||||
@@ -0,0 +1,68 @@
|
||||
## What a screen reader says
|
||||
|
||||
Every control on the phone is named by what it does, in the app's own
|
||||
words, and where the desktop already has a word for something it is the
|
||||
word used here too — Seek, View album artwork, Close artwork viewer. An
|
||||
icon-only control carries an `aria-label`; one with visible text is named
|
||||
by the text it shows.
|
||||
|
||||
The nav is a tab set: `role="tablist"` on the bar, `role="tab"` and
|
||||
`aria-selected` on the five destinations, and the count badge round
|
||||
seventeen added is part of the tab's own name — "You, 3 transfers",
|
||||
"Community, 2 unread" — rather than a digit read out before the word it
|
||||
belongs to. The badge itself is `aria-hidden`, because the number is in
|
||||
the name now.
|
||||
|
||||
The two sheets are dialogs: `role="dialog" aria-modal="true"`, the player
|
||||
sheet named for what is playing, the half sheet reading its own first
|
||||
title back out of the DOM so that twenty-seven kinds of sheet need no list
|
||||
of names to keep in step. Opening one puts the focus somewhere sensible —
|
||||
the player sheet on its Close, the half sheet on its grab handle, which is
|
||||
a real button now ("Close this sheet") and the first dismissal a screen
|
||||
reader could ever reach; the scrim behind it never was one.
|
||||
|
||||
A sheet that is down is `inert` and `aria-hidden`. Neither sheet is
|
||||
unmounted — they are translated off the bottom of the screen, and the half
|
||||
sheet deliberately keeps its last body rendered through the slide down —
|
||||
so before this the closed sheets' rows, the hidden mini bar and the nav
|
||||
under an open sheet were all still in the accessibility tree, and TalkBack
|
||||
could swipe into every one of them.
|
||||
|
||||
There is one live region, `.sr-live`, mounted for the life of the app and
|
||||
clipped to a single pixel, because a region that appears with its words
|
||||
already inside it is announced reliably by nobody; an announcement here is
|
||||
a change of text in a node that was always there. That is what carries an
|
||||
error and a notice, so an Undo offer is actually read out — and the Undo
|
||||
button itself stays reachable, only the sentence beside it is hidden from
|
||||
the tree so it is not said twice.
|
||||
|
||||
Artwork that opens the viewer is a control, not decoration. Artwork that
|
||||
is decoration carries `alt=""` and every icon is `aria-hidden`, so the
|
||||
tree never reads a filename out. The waveform is `role="slider"` with a
|
||||
value and arrow keys where it can seek, and `aria-hidden` on the mini bar
|
||||
where it is only a picture of the position. Switches say on or off, choice
|
||||
chips say pressed, and the two long descriptions and the gap line say
|
||||
expanded.
|
||||
|
||||
The check, so this does not quietly rot: the walk's probe sweeps every
|
||||
interactive element in the live DOM and counts four things — the ones with
|
||||
no accessible name (`a11yAnon`, with `a11yWho` naming the first three
|
||||
offenders so one run tells you what to add), the ones whose name is junk
|
||||
(`a11yBad`: bare digits, a filename, a lone glyph, an essay), the ones
|
||||
sitting in a layer that is off-screen but still in the tree (`a11yGhost`)
|
||||
and the ones that look chosen without saying so (`a11yState`). All four
|
||||
are asserted at zero on Home, the library, a record, a catalog record, the
|
||||
artist page, You, the taste page, a half sheet, the player sheet and the
|
||||
viewer — and the anonymous count on a room and on both shapes of prompt
|
||||
besides, because a room's message box and the prompt's own field are where
|
||||
the fields this round finally named actually live: assert it only where
|
||||
they are not and the round guards everything except its own work.
|
||||
|
||||
One known exception, left alone on purpose: the per-row ⋯ is a
|
||||
`role="button"` inside the row's own `<button>`, and the mini bar's three
|
||||
transport buttons sit inside its `role="button"` the same way. That is
|
||||
invalid ARIA, and both ends are named so nothing is anonymous; unpicking
|
||||
it means splitting `.trk` (one flex row with a whole-row `:active`) and
|
||||
rewriting the walk's `.trk .r` selectors, for no gain a screen reader
|
||||
would notice.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* the number this round exists to hold at zero, and who broke it */
|
||||
a11yAnon: a11yNone.length,
|
||||
a11yWho: a11yNone.slice(0, 3).map(whatis).join(' | '),
|
||||
a11yBad: a11yAll.map(function (el) { var nm = aname(el); if (!nm) return ''; var j = junk(el, nm); return j ? j + ':' + whatis(el) + ':' + nm.slice(0, 22) : ''; }).filter(Boolean).slice(0, 3).join(' | '),
|
||||
/* a layer that is off-screen but still in the tree: the closed
|
||||
sheets, the hidden mini bar, the nav under a sheet */
|
||||
a11yGhost: (function () {
|
||||
var n = 0;
|
||||
[].forEach.call(document.querySelectorAll('.sheet:not(.up), .halfsheet:not(.up), .mini.hidden, .nav.gone'), function (h) {
|
||||
if (h.matches(A11Y_SEL) && exposed(h)) n++;
|
||||
n += [].filter.call(h.querySelectorAll(A11Y_SEL), exposed).length;
|
||||
});
|
||||
return n;
|
||||
})(),
|
||||
/* a control that LOOKS chosen and does not say so */
|
||||
a11yState: (function () {
|
||||
var bad = [];
|
||||
[].forEach.call(document.querySelectorAll('.row .sw'), function (sw) {
|
||||
var b = sw.closest('button, [role=button]');
|
||||
if (!b || !exposed(b)) return;
|
||||
var want = sw.classList.contains('on') ? 'true' : 'false';
|
||||
if (b.getAttribute('role') !== 'switch' || b.getAttribute('aria-checked') !== want) bad.push('sw:' + aname(b).slice(0, 18));
|
||||
});
|
||||
[].forEach.call(document.querySelectorAll('.segtabs button, .np-faces .fbtn, .nav button'), function (b) {
|
||||
if (!exposed(b)) return;
|
||||
var want = b.classList.contains('on') ? 'true' : 'false';
|
||||
if (b.getAttribute('role') !== 'tab' || b.getAttribute('aria-selected') !== want) bad.push('tab:' + aname(b).slice(0, 18));
|
||||
});
|
||||
[].forEach.call(document.querySelectorAll('button.chip'), function (b) {
|
||||
if (!exposed(b) || b.classList.contains('tchip')) return;
|
||||
if (!b.hasAttribute('aria-pressed')) { if (b.classList.contains('on')) bad.push('chip:' + aname(b).slice(0, 18)); return; }
|
||||
if (b.getAttribute('aria-pressed') !== (b.classList.contains('on') ? 'true' : 'false')) bad.push('chip:' + aname(b).slice(0, 18));
|
||||
});
|
||||
return bad.slice(0, 3).join(' | ');
|
||||
})(),
|
||||
/* the layer on top: its role, whether it is modal, its name, and
|
||||
whether the focus went with it */
|
||||
dlg: (function () {
|
||||
var d = document.querySelector('.viewer') || document.querySelector('.syncroom')
|
||||
|| document.querySelector('.halfsheet.up') || document.querySelector('.sheet.up');
|
||||
if (!d) return '';
|
||||
var nm = (d.getAttribute('aria-label') || '').trim();
|
||||
return (d.getAttribute('role') || '-') + '/' + (d.getAttribute('aria-modal') || '-') + '/' + nm.slice(0, 34) + '/' + (d.contains(document.activeElement) ? 'focus' : 'nofocus');
|
||||
})(),
|
||||
navTabs: [].map.call(document.querySelectorAll('.nav button'), function (b) { return aname(b) + (b.getAttribute('aria-selected') === 'true' ? '*' : ''); }).join('|'),
|
||||
announce: ((document.querySelector('[data-live]') || {}).textContent || '').trim(),
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
/* ── what the machine says ───────────────────────────────────────── */
|
||||
/* A tab's own name: the section, and when the dot is up, what the
|
||||
number counts. "You, 3 transfers" — not a stray 3 read out before
|
||||
the word it belongs to. */
|
||||
function tabCount(id) {
|
||||
if (id === "you") return activeTransfers().length;
|
||||
if (id === "community") return unreadTotal();
|
||||
return 0;
|
||||
}
|
||||
function tabName(id, label) {
|
||||
const n = tabCount(id);
|
||||
if (!n) return label;
|
||||
return label + ", " + n + (id === "you" ? " transfer" + (n === 1 ? "" : "s") : " unread");
|
||||
}
|
||||
|
||||
/* The one live region. It is mounted for the life of the app on
|
||||
purpose: a region that appears with its words already inside it is
|
||||
announced reliably by neither TalkBack nor VoiceOver, so an
|
||||
announcement here is a TEXT CHANGE in a node that was always there.
|
||||
The desktop does the same (FollowToast's always-mounted .layer,
|
||||
PreviewOverlay's .sr-announce). Never drawn — shell.css clips it to
|
||||
a pixel. */
|
||||
const announce = $derived([player.error || lib.error, notice.text, notice.action?.label].filter(Boolean).join(". "));
|
||||
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user