Skip to content

did lots of updates backend and frontend, i loved your design but qml frontend was the best option for performance - #21

Open
neur0map wants to merge 164 commits into
ashmitvoid:mainfrom
Ryoku-dev:main
Open

neur0map wants to merge 164 commits into
ashmitvoid:mainfrom
Ryoku-dev:main

Conversation

@neur0map

@neur0map neur0map commented Sep 8, 2026

Copy link
Copy Markdown

your design was good but it really was causing a lot of ram spikes and issues with audio.. if you want deeper description of what i did let me know.

Three interlocking causes made each track start refetch the 2.9 MB player.js
and spawn a fresh hidden WebKit process:

- the cipher and PoToken webviews were torn down 15 s after use, which any
  song outlives; keep them while media is loaded and release after 5 min idle
- the player hash was re-read from iframe_api on every resolve because
  current_hash.txt was written but never read; pin it for the cache TTL
- a WEB_REMIX HEAD 403, routine on capped videos, ran the full self-heal
  (delete player.js, drop the bridge and the PoToken session) each time;
  allow one heal per ten minutes

Measured on a Ryoku desktop: six consecutive track changes with no fetch and
no webview build, where before each one paid ~1.2 s and a WebProcess.
mpv titled the stream by its URL, so the PipeWire node carried the full
signed googlevideo link. Pass a per-file force-media-title (Artist - Title)
through loadfile's options, quoted for the command parser and
length-prefixed for the option parser so any title round-trips verbatim.
Includes a probe example that asserts the round trip.
The reveal was gated on the Ryoku token IPC and retried for ~1.85 s against a
1.5 s native failsafe, so a cold WebKit mount was routinely revealed natively
and unstyled. Race the palette against 250 ms; the native failsafe moved to
4 s in the previous commit's lib.rs change.
Tauri sets cfg(dev) whenever custom-protocol is absent, so the source
PKGBUILD's plain cargo build produced a binary that opened build.devUrl.
Default the feature on; cargo tauri dev still passes --no-default-features.
Both seek sliders hold the dragged value locally and commit it on the
input's change event. Release the thumb outside the window (the bar sits
on the bottom edge) and WebKitGTK delivers neither change nor pointerup;
the local value then shadowed every later position tick and the timeline
sat frozen for the rest of the session while playback carried on.
Reproduced by dragging past the window edge: MPRIS kept ticking, the bar
did not. A shared guard now commits the pending drag on pointerup,
pointercancel, lostpointercapture, window blur, or the first pointer
movement with no button held, and a track change ends a drag outright.
Home toggled ryo-is-scrolling on <main> on each scroll event and again
110 ms after the last one, and the queue/lyrics wheel helper did the same
on its scroller. Rules keyed on that class matched every <img> and every
promoted .ryo-art-wash layer, so each toggle was a full style recalc plus
compositing-layer churn, twice per wheel notch. Scrolling is otherwise
paint-bound in WebKitGTK (measured the same 60% of a core with and
without the toggle), so the class and its rules go rather than get
cheaper.
WebKitGTK 2.42's feature API can flip compositing borders and repaint
counters on, which is how to see what a scroll actually repaints; the
webkit2gtk crate does not bind it yet, so the five calls are declared
here and gated on an env var that is normally unset.
The cold-start reveal failsafe grew to 4000 ms in 531c2b5 but the
invariant still asked for 1500; the private-path scan matched the .git
file of a git worktree; two files had drifted from rustfmt.
The host calls lastfm::spawn from Tauri's synchronous setup closure, on
the main thread with no ambient Tokio context; the tokio::spawn that
replaced tauri::async_runtime::spawn panicked there at startup. Take the
runtime handle explicitly (the daemon will pass its own) and format the
files the extraction left unformatted.
github-actions Bot and others added 30 commits September 8, 2026 07:21
The page-stack enter animation is a ParallelAnimation of OpacityAnimator and
YAnimator, both timed by Tokens.durFastEffects. That token zeroes under
Ryoku's Reduce motion, and a render-thread Animator with duration 0 never
applies its `to` value -- so pageStack was left at its `from` opacity 0 on
every route change and on first load, blanking every page while the sidebar,
player and daemon kept working. Route the three enter call sites through a
reenter() that sets the end state directly when the duration is 0, and
restarts the animation otherwise.

Closes #193
The art column sized its cover with `artPx: min(width, body.height - 30)`,
reading the column's own assigned `width` and the body RowLayout's assigned
`height` -- both layout *outputs* -- and fed them into the Artwork's
Layout.preferredWidth/Height and the title block's Layout.maximumWidth, which
are layout *inputs*. Every layout pass therefore re-polished the child inside
its own updatePolish, the `ColumnLayout called polish() inside
updatePolish()` loop that stuttered window moves and resizes.

Size the cover from layout inputs instead: the root's width/height and the
header row's implicit height. The expression expands body.height to exactly
what it resolved to before, so the cover is pixel-for-pixel the same at any
size -- it just no longer reads the layout's own output back into it.

Closes #196
…loss; feat: batch collection downloads with smart dedup

YouTube: the anonymous fallback clients now require a valid visitorData,
and the daemon's single non-retrying startup bootstrap left users whose
first fetch failed permanently bot-gated ('Skipped (unavailable)' on
every track). The bootstrap now retries with backoff, a gated resolve
surfaces as ResolveError::BotGated, and AppState re-bootstraps a fresh
visitorData on the spot (5-min cooldown, persisted) and retries once.

Spotify (Premium users saw a sign-in gate every launch and every track
skipped as unavailable): the startup restore is now announced via
spotify-auth restored/restore_failed instead of finishing silently
after the client snapshot; an invalidated librespot session is detected
(alive() was dead code) and recovered once per credential lifetime
under a lock that serializes it against the startup restore; a
sign-in-required failure stops the queue with the real reason instead
of toast-storming skips; and the Spotify device id is persisted per
installation instead of re-randomising every launch.

Downloads: playlists get a Download playlist menu item (parity with
albums); albums and playlists admit through one enqueue_collection RPC
that gathers every continuation page and dedups the whole collection in
a single folder pass. Smart dedup (FolderIndex) skips audio already on
disk under the exact stem, a collision suffix, or the same normalized
'artist title' from a different upload id, for single tracks too.
SoundCloud sign-in (opt-in; guest browsing/playback is unchanged):
- crates/soundcloud gains an auth module: the captured OAuth bearer's
  lifetime is read from its JWT claims, and near-expiry tokens are
  rotated through the site's own refresh exchange under a single-flight
  lock (the refresh token is single-use; a double spend would log the
  session out). send() carries Authorization: OAuth <token>.
- ryotunesd's soundcloud_login opens the site's sign-in in a WebKitGTK
  window (allow-listed to SoundCloud + its IdPs) and harvests the
  oauth_token/oauth_refresh_token/connect_session triple from the
  cookie jar, watching both load-finished and cookie-changed since the
  SPA lands the token without navigating.
- The daemon proves the bearer against /me before persisting it under
  the daemon-only soundcloud_auth settings key (never UI_SETTINGS),
  re-installs it at startup, announces signed_in/signed_out/
  restore_failed via a soundcloud-auth event, and clears + explains an
  expired session on first use. New RPCs: soundcloud_sign_in/out/status;
  the subscribe snapshot carries the soundcloud block.
- Settings' SoundCloud row becomes a real sign-in control.

Merged home: get_home (unfiltered) now composes one HomePage — the
YouTube Music spine (works signed out, the default source) plus every
signed-in provider's shelves: SoundCloud's own playlists/likes/
following ahead of its discover rows, Spotify's made-for-you rows
whenever a Premium session exists (with on-demand recovery). A dead
spine or provider degrades to the rest instead of blanking the page;
continuation stays the YouTube feed's. Cards already navigate/play
across providers via the existing id-prefix dispatch, so the QML
renderer needed no change beyond dropping the now-wrong Spotify
sign-in gate from Home.
- Collection downloads (albums/playlists) were broken from the start:
  CollectionEntry lacked #[serde(rename_all = "camelCase")] while the
  client posts camelCase, so enqueue_collection always died at
  deserialization with "missing field video_id". Single-track downloads
  were unaffected. The unit test built the struct in Rust and never
  crossed the JSON boundary, which is how it shipped green.
- Batch jobs now carry their collection label/kind; the downloads queue
  and history collapse every batch into one card (cover, kind,
  aggregate progress, expandable track rows) instead of a wall of
  anonymous rows. Collection tracks land in a folder named after the
  collection on disk; FolderIndex's recursive stem walk keeps dedup
  working across layouts.
- New crates/ryotunesd/src/diagnostics.rs: a ring buffer + rotating
  file ($XDG_DATA_HOME/dev.ryoku.ryotunes/logs/ryotunesd.log) fed by
  a tracing layer (warn+), the server's RPC-error chokepoint, the panic
  hook, download worker failures, and the client's log_client_error
  RPC. The ring seeds from the file tail at startup, so evidence
  survives a crash or upgrade. The client buffers its own failures
  (not-connected rejects, socket drops, error toasts) while the daemon
  is down and flushes them on reconnect.
- Settings > Diagnostics page: filters by level/source/text, copy-all
  to clipboard, open-folder, manual refresh (no polling).
- Fixed a pre-existing flake: Temp::new() keyed test dirs on
  pid+nanos, which collides across parallel test threads sharing a
  coarse clock; a process-local counter makes them unique (0/25 runs
  after, 1/12 before).
- client/Logs.qml needed the Quickshell import for the Singleton type;
  DiagnosticsPage renamed its snapshot property from 'data' because
  Item.data is the default children property and shadowing it silently
  swallowed every child item.
enqueueCollectionFromPage already builds camelCase entries with
toEntry; enqueueCollection then re-mapped them, reading video_id off
objects that carry videoId. JSON.stringify dropped the undefined keys,
so the client posted rows without any videoId at all — the second,
stacked half of the broken batch download (the serde snake_case wire
format was the first). Entries now map once and post verbatim.
clear_caches now wipes every layer that can wedge stream resolution —
cached stream URLs + lyrics, mpv's on-disk audio bytes, the stored
PoToken, and the per-video WEB_REMIX failure blacklist — and rotates
the anonymous YouTube playback identity (visitorData) on the spot, the
manual fix for "Couldn't load this track — YouTube rejected the stream
link". The RPC gained a {visitorDataRefreshed} result and a rotate
flag: the settings button rotates (default), a quality change clears
only the URL cache and leaves the identity alone. An offline re-fetch
still drops the token and reports it, so the next launch or a
bot-gated resolve re-bootstraps. Wired through the daemon, the Tauri
host, the Svelte dialog and the native QML settings page.

Verified live: rotate:false keeps the stored identity, rotate:true
replaces it, and the daemon answers the new shape on all three calls.
The embedded webview capture could not survive captchas or IdP popups, so
users reported never getting signed in. Connect now imports the session
straight from the browser's cookie stores (Gecko plaintext, Chromium v10
AES-CBC), default profile first, proving the bearer against /me before
persisting it; when no session exists it opens soundcloud.com and polls
the stores (mtime-guarded) until the sign-in lands or five minutes pass.
The client mirrors the new waiting/expired states.
install_update left 'quit and reopen' as the user's chore. The client now
spawns a detached helper that flock-waits for the old daemon's socket lock
to release, then execs the launcher (socket-activating the new daemon and
opening the new client), and asks the daemon to quit. The manual guidance
stays as the fallback text if the helper fails.
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