Skip to content

Release 0.6.0 - #12

Merged
Nxssie merged 32 commits into
mainfrom
dev/0.6.0
Sep 14, 2026
Merged

Nxssie merged 32 commits into
mainfrom
dev/0.6.0

Conversation

@Nxssie

@Nxssie Nxssie commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Wren 0.6.0

A Home tab with real feeds, a home-screen widget on Android, and a player that survives bad coverage.

Android

  • Home-screen playback widget: cover, title and artist, transport, and a per-platform keep button (SoundCloud like, YouTube collection). Follows the engine live and matches the launcher's exact size.
  • Streaming under poor coverage: minutes of audio buffered ahead, more per-chunk retries, and a dropped connection reloads the same track where it stopped instead of skipping.

Browse

  • Home leads the tab bar, built from the artists you follow and your recent plays; Explore gets real feeds.
  • Now Playing leaves the tab bar and draws over the current tab; the lyrics live in its sheet.
  • Library lists fill page by page as they arrive and keep what they fetched.

SoundCloud

  • HLS renditions play when a track offers no progressive one.
  • Tracks served only encrypted are flagged from the listing: dimmed, locked and never queued. The set persists across restarts.
  • Sign-in goes through SoundCloud's own page and the session renews instead of expiring into a re-login.
  • Downloads happen only when asked.

Fixes

  • Lyrics fall back to a search when the exact lookup misses.
  • A failed YouTube search reports the failure instead of an empty result.
  • Lazy lists are keyed by position, so duplicate ids no longer break them.
  • HTTP requests carry an app user agent.

Full log: git log v0.5.1..v0.6.0

- Both APIs could only hand back a whole list, which is what made the library
  screens wait through every page before showing anything; a page and its
  cursor are now the primitive, and the list versions are a walk over it.
- A failed page stays fatal when nothing has arrived and is logged once
  something has, so a short list cannot be mistaken for a complete one.
- The cursor is opaque on purpose: YouTube hands back a `nextPageToken` and
  SoundCloud a `next_href` URL, neither of which the caller should interpret.
- Every entry into the Library re-fetched everything: the list walks page by
  page (`ceil(n/50)` serial requests) and then enriches it in chunks, so a
  1.000-track library is ~8 s of the same work on every visit — and the screens
  are rebuilt on every tab change.
- The lists are now cached per account for ten minutes, playlists included,
  with the load under the lock so the warm-up racing a screen shares one walk
  instead of starting a second.
- `LibraryWarmup` builds the YouTube library in the background on start-up and
  on sign-in (the moments the fetch would otherwise happen in front of the
  user), which is what makes the first open instant too.
- The screen now receives the library progressively — the first page as soon as
  it is enriched, the rest while the user is already reading — instead of one
  list at the end of the walk.
- The finished list lands in the existing cache, so the warm-up and the next
  visit are still one emission; stopping mid-walk stores nothing, which is what
  keeps a cancelled fetch from becoming the cached answer.
- `musicArtists` is shared by the one-shot list and the flow so both narrow the
  subscriptions to music the same way.
- The screens waited for the whole walk before rendering anything; they now
  collect the pages and show the first one, enriched, as soon as it exists —
  the rest appends underneath while the user is already reading, playlists
  included.
- `Flow.catch`, not `runCatching`: it does not catch the collector's
  cancellation, so leaving the tab mid-walk can neither show a message nor leave
  a half list cached.
- Opening a second playlist cancels the first walk instead of racing it.
- `null` means "not fetched yet" and the empty hint meant "fetched, and there is
  nothing": treated the same, opening Library showed "no liked songs yet" for a
  beat before the first page arrived, whatever the library actually held.
- The open playlist had the same problem from the other side — its tracks started
  as an empty list, not as nothing — so it flashed "this playlist is empty" on
  the way in.
- Emitting per page means a page can legitimately arrive empty — the most
  recent fifty likes may hold no music at all once filtered — and the screen
  read that as "no liked songs yet" before the next page brought the list.
- `loading` now stays until something arrives, or until the walk ends, and the
  per-page log says how far it got, so a partial answer is never mistaken for
  the finished one.
- YouTube Music's own `FEmusic_home` and `FEmusic_explore` browse pages replace the
  "discover" tab, which only existed because the Data API has no feed.
- `discover` becomes `explore` and loses its per-platform label: now that both platforms
  have a real feed there is nothing left for a label to disclaim.
- Home keeps the radios seeded by local plays, because InnerTube rejects the Google token
  and the editorial shelves therefore come back geographic rather than personal.
- A collection walk follows continuations, so a long playlist is not silently truncated.
- the bar is home/search/explore/library on both platforms, which is what makes room for
  the Home tab; Now Playing stops being a destination because tapping the item that is
  playing already opens the player, and the bar opens it from anywhere else.
- the media-widget tap goes to the player screen, and back only closes it when something
  is behind it, so a widget tap at a cold start still leaves the app as it did before.
- a card can now open another feed, so a mood or genre card does something instead of
  silently nothing; the card says which of the two it is, because asking the network to
  find out would be a second round trip and a guess.
- SoundCloud lists the same system playlist twice inside one selection, which crashed
  Explore at measure time with "Key ... was already used". Four other lists keyed items
  by id alone and had simply not been handed a repeat yet: search results, library
  playlists, library artists and a collection's tracks.
- the repeated card is dropped as well, because rendering the same playlist twice tells
  the user nothing, and a list that repeats an item should not be a way to take the app
  down.
Home and Explore feeds, the reordered tab bar and the duplicate-key fix.
- the interface defaults each `…Flow` to an empty list, so the Library read as empty while the
  warm-up behind it had already fetched 506 tracks; only SoundCloud implemented them, and the
  four page imports sitting unused in this file were the shape of the work that was never done.
- a playlist's cache key never interpolated the account, so one account's tracks could be served
  to the next, against the rule the rest of the class keeps.
- the status was never checked and nothing was logged, so a 403, a 429 and a bot check all
  parsed as "no results": a throttled request and an empty catalogue were the same outcome, and
  neither left a trace to diagnose from.
- a body that does not parse is reported too, with the first of it, which is what named the
  resolver failure in the end.
- lrclib answers `okhttp/4.12.0` with a Cloudflare 520, deterministically, on both of its
  endpoints, while the same request from curl or a browser is served — so every lyrics lookup
  failed and the log blamed the network.
- a default only: InnerTube identifies itself by user agent, so callers that set one keep it.
  Four call sites were relying on the default, this one was simply the first to hit a service
  that cares.
- `/api/get` compares track name, artist and duration exactly, and a YouTube title carries
  "(Official Video)" and "(Remastered)" and friends, so it missed lyrics that exist; `/api/search`
  finds them, and the duration is checked before a hit is used so a live take is not pasted onto
  the studio recording.
- the title and artist are cleaned of upload noise first, the takes that name a different
  recording (Live, Acoustic, Demo, feat.) are kept, and the answer is cached so a track with no
  lyrics does not ask again on every play.
- a request that failed now throws instead of answering null: "there are no lyrics" and "we could
  not ask" are different answers, and lrclib hands out 503s under load.
…player

- the sheet holds the queue and the lyrics, so reaching them is the same gesture that opens it
  rather than a second surface with its own way back; the player stays behind them.
- the line being sung is centred and larger and the neighbours shrink, because scaling the active
  line up pushed a long line past the edge of the screen, and that is the one line you have to be
  able to read. Scaled through graphicsLayer, so the item heights stay fixed under the auto-scroll.
- a synced line seeks to its own time on tap, on desktop as well as the phone.
- the mini player is hidden while the player itself is open: the collapsed sheet already carries
  the same track and expands on a tap, so a second copy only repeated it in less detail.
- the player draws over the tab instead of replacing it, so closing it returns to the playlist,
  the search or the scroll position that tab had open rather than rebuilding it at its root.
…ndpoint

- the app used to build the authorize URL itself, with the web player's client id and a
  redirect to a URI SoundCloud owns; that page is the one DataDome guards, and it never got
  past it. Loading the ordinary sign-in and letting SoundCloud's page run its own flow with
  its own client id and redirect signs in cleanly — measured on the device: sign-in, code,
  post-signin redirect, session, no challenge anywhere.
- the session is read from the page: `oauth_token` out of the cookie jar, with the page's own
  storage as the fallback. It is validated against /me before it is stored, the same call the
  pasted-token path uses, so a token that does not work is refused rather than kept.
- no redirect interception is needed any more, which was the only reason the embedded browser
  had to be a WebView we drive by hand.
- the button said `sign_in_with_browser`, which is no longer true of anything.
- `buildAuthRequest`, `parseCallback` and `exchangeCode` had no caller once the sign-in page
  started minting the session itself, and the nonce/state dance and its PKCE tests existed only
  to serve them. `computeCodeChallenge` and `generateCodeVerifier` stay: they live in OAuthFlow
  and are still Google's.
- what is left is the token shape and the refresh. The refresh is unreachable while nothing
  stores a refresh token — the page keeps the session in a cookie — which is a known gap
  rather than a finished path, and the reason the access token expiring means signing in again.
…-login

- the app meant to keep the session alive: `refreshToken` and `expiresAt` are persisted and
  `ensureValidToken` was called before every api-v2 request. But nothing ever populated either
  field, so it returned early on every single call — dead code wearing the shape of a safeguard,
  which reads worse than nothing. An hour in, the session simply stopped working.
- the page keeps both halves in cookies. `oauth_token` is an 861-character JWE; the refresh token
  is 32 characters under `oauth_refresh_token`, which is why looking for it in the first place
  was worth doing before designing around its absence.
- a refresh has to be presented by the client that got the grant, and the mobile flow's id is not
  the one scraped for api-v2 — presenting the wrong one is what `invalid_grant` was saying. The id
  is read off the authorize URL on the way past and carried on the session.
- renewal is triggered by the API refusing the token, not by a clock: the page never exposes a
  lifetime, and inventing one would have been a guess whose failure mode is silent.
- the resolution for a download is anonymous — no token, just the scraped public client id — so
  this is not really about the account. It is about the client id: a client fetching whole files
  is the behaviour those services police, and it is what gets an id rotated, which breaks the app
  for everyone using it including this one.
- off unless turned on, with the toggle and its reasoning in the accounts dialog. Saving your own
  upload stays possible without archiving everything you play being the default.
…it works

- it talks to undocumented internal endpoints, scrapes the public client id those web players use,
  and reads the SoundCloud session out of its own WebView after sign-in. All of it is against the
  terms of use, and none of it is visible from a feature list.
- published so it can be read and run by whoever chooses to, each on their own account and at
  their own risk. No credentials ship; a fork keeps the warning.
- the scraped client id is cached for six hours and is the only thing authenticating these calls.
  When SoundCloud rotates it, every stream failed for the rest of that window, while the library
  path — which has re-scraped on a 401 for a while — healed itself. Same signal, now the same
  response: invalidate and retry once.
- a 5xx, or a connection that never got there, returned null and cost the listener the track.
  One retry with a short pause now.
- `runCatching` there also swallowed CancellationException, so changing track mid-resolve was
  recorded as "no stream" and the player skipped a second time on top of the skip that had just
  cancelled it.
- all four ways the resolution can fail now say which one it was. Before they returned null in
  silence and the only trace was the engine's "No stream URL …; skipping", which named the
  symptom and never the cause.
SoundCloud sign-in through its own page, session renewal, lyrics read in the sheet,
and the risk work around them: SoundCloud downloads off unless asked for, and a README
that says what the app does to those accounts instead of only what it does for you.
- the InnerTube home feed rejects the Google token, so its shelves were geographic
  rather than personal; a signed-in Home is now built from what only the account
  knows, leaving the editorial feed to guests
- share the subscriptions walk between the library and Home's release shelf
- now playing with previous / play-pause / next, driven by the same playback
  service so the controls survive the app being backgrounded
- the widget has no seek bar, so position stays out of its state and a repaint
  only follows track, artwork and transport changes
- Glance runs provideGlance once per session and answers later updates by
  recomposing the same lambda, so title, transport and artwork captured outside
  it stayed frozen: play never turned into pause and the cover lagged a track
- SizeMode.Exact and LocalSize replace the launcher options, which reported a
  taller card than drawn and let the artwork spill out of the frame
- YouTube's 4:3 thumbnails are cropped to their 16:9 frame instead of the bars
- restyle after the YouTube Music widget: flush square cover, title with artist,
  plain controls spread evenly, neutral tint from the cover
- a SoundCloud track gets a like, a YouTube track a like through the Data API,
  which is what puts it in the library's collection; hidden when signed out
- the widget observes both like sets and looks the YouTube rating up per video
- the action reaches the engine through the playback service like the transport
- a network error used to skip to the next track at once; the engine now waits
  with backoff, drops the cached URL and reloads the same track where it stopped
- buffer minutes of audio ahead and give each chunk more in-loader retries
- an expired or IP-bound URL after a network switch takes the same path
- newer uploads offer HLS only; fall back to it when there is no progressive
  transcoding, MP3 ahead of Opus
- SoundCloud playlist URLs carry no .m3u8, so the media item states the type
- monetised tracks still list plain MP3 renditions but answer 404 for them;
  only the encrypted ones are served, and Wren does not touch those
- a track listing any encrypted rendition is marked as it is parsed, so song
  rows dim it, show a lock and refuse the tap before any playback attempt
- the engine keeps a readable notice on the bar when one is skipped anyway
- the player is not a browse surface, so the YouTube/SoundCloud switch above
  it changed nothing and read as part of the screen
- the set only filled as listings were parsed, so a restored queue showed a
  protected track unlocked until its list loaded again
- stored in the state dir with the same temp-file-and-rename write as the
  playback state; loaded right after AppDirs on both platforms
@Nxssie
Nxssie merged commit 697df9d into main Sep 14, 2026
5 checks passed
@Nxssie
Nxssie deleted the dev/0.6.0 branch September 14, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant