Skip to content

Playlists, streaming, a queue, autoplay and light/dark themes for the desktop player - #17

Merged
amiralibg merged 10 commits into
amiralibg:mainfrom
alimohammad0816:feature/playlists-and-player
Nov 2, 2026
Merged

amiralibg merged 10 commits into
amiralibg:mainfrom
alimohammad0816:feature/playlists-and-player

Conversation

@alimohammad0816

@alimohammad0816 alimohammad0816 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Playlists, full-length streaming and a proper queue for the desktop player

The desktop player could only play the on-disk library. This turns it into a full music player:

  • Your own playlists — a Playlists tab (and a list in the sidebar): create, rename, drag to reorder, remove, delete, play, shuffle. A playlist can mix songs on disk with songs that aren't downloaded yet; each row says which.
  • Download all — one job for every song in the playlist that isn't on disk yet. Rows flip to "on disk" as they finish; pressing it again after a failure fetches only what's left.
  • Export / import — .unstream.json (lossless, imports on another device and re-matches files already there) or .m3u8 for any other player. Exports go into the music folder's Playlists/ and the toast offers "Reveal in folder".
  • Online playback — any album/playlist row plays in full on the desktop (the web keeps its 30-second previews). "Save as playlist" turns an opened album or playlist into your own.
  • Queue — a panel with what plays next (in play order, shuffled or not): drag to reorder, remove, jump, clear. Every row gets Play next / Add to queue / Add to playlist.
  • Autoplay (on by default, switchable in the queue panel and in Settings) — when the queue is about to run out, similar songs are queued after it, seeded by whatever is playing: Deezer's artist radio → related artists → top tracks, with YouTube's Mix for the song as the fallback (which is what makes it work for Persian artists Deezer barely knows). Your own queued songs always play first; turning it off removes what it queued.
  • Themes — a light mode, and a blue palette beside the original lime (which stays the default — nothing changes for anyone who doesn't pick). Two choices in Settings: mode (system / light / dark) and palette (original / blue). Every colour is a token; a palette or mode is another set of token values under data-palette / data-theme on <html>, so no component knows which theme it's in.
  • Fix: a stale player-client pin broke playback and downloads. The desktop pins YouTube clients to tv,web, which now fail (tv: "The page needs to be reloaded", web: no audio). ytdlp.run() keeps the pin as the first try and retries once with yt-dlp's own client choice. Segmented-only (HLS) uploads are fetched into a small cache for streaming instead of failing.
  • The player resolves the next streamed song while the current one plays, and restores the last queue and position on launch, paused.

How it's built (details in docs/DESIGN.md → "Playlists, streaming and the queue")

  • Entries are songs, not files. Catalog metadata plus a path relative to the music folder — never a library id, which is a hash of a path on one machine. On-disk status is matched against the library scan on every read (path → title + lead artist → title + length).
  • Stored in a file (PLAYLISTS_PATH, beside lyrics.db; the Tauri launcher sets it), written atomically, never overwritten if unparseable — not in localStorage, whose bucket moves with the sidecar port.
  • /api/stream relays audio through the backend so it follows the connection/VPN setting and YouTube's per-URL headers. Upstream ranges are capped at 4 MB (googlevideo throttles open-ended requests); progressive m4a/mp3 is preferred for WebKit; resolved sources are cached until shortly before expire.
  • Desktop-only by default (UNSTREAM_PLAYLISTS / UNSTREAM_STREAMING to opt in): a hosted instance would otherwise share one playlists file between visitors and relay every stream over its own bandwidth.
  • New copy in English and Farsi; CONTEXT.md gains صف پخش / پخش / پخش خودکار / تم / رنگ / خروجی / وارد کردن. docs/DESIGN.md gains a section on the colour system.

Testing

  • backend: 26 new tests (test_playlists.py, test_stream.py, test_recommend.py) — storage, dedupe, reorder, untrusted-input cleaning, library matching, JSON and M3U round trips, download of missing songs, range bounding, source cache and re-resolve on a dead URL, suggestion seeding, top-up and the YouTube fallback, the player-client retry and cached HLS ranges. Full suite: 183 passed.
  • frontend: npm run lint, format:check, build clean.
  • Manually, against the real backend in desktop mode (?desktop-preview): streamed a Deezer album track end to end, saved the album as a playlist, added a library file to it, exported both formats, re-imported the JSON (local file re-matched), downloaded a playlist's missing song and watched the row flip to on disk, played from the playlist, queue panel, session restore after reload, autoplay carrying on from a finished queue into a streamed suggestion and clearing when switched off, all four palette × mode combinations, both RTL (fa) and LTR (en). The client-pin fix was reproduced and verified against the packaged sidecar with the launcher's exact environment.
  • Not tested: a packaged Tauri build (no Rust toolchain here) — the only Rust change is one PLAYLISTS_PATH env var in spawn_backend.

Shortcut note: Playlists takes ⌘/Alt+4, so Settings moves to ⌘/Alt+5.

Playlists are stored as catalog metadata in one JSON file beside lyrics.db
(PLAYLISTS_PATH), never as library ids, which are hashes of a path on this
machine. Which rows are on disk is matched against the library scan on every
read, so a song downloaded or copied in later shows up without the playlist
being told. "Download all" queues exactly the rows that matched nothing, as
one job whose track ids are the rows' own.

Exports land in the music folder's Playlists/ as .unstream.json (what import
reads) or .m3u8 for other players; import takes the file's text, so no path
ever comes in from a request.

/api/stream plays a song that isn't on disk: it picks an upload the way the
downloader does and relays the audio through the backend, so it follows the
connection setting and YouTube's per-URL headers. Upstream ranges are capped
at 4 MB, since googlevideo throttles open-ended requests; resolved sources
are cached until shortly before their URL expires.

Both are desktop-only unless UNSTREAM_PLAYLISTS / UNSTREAM_STREAMING say
otherwise: a hosted instance would share one playlists file between every
visitor and relay every stream over its own bandwidth.
- Playlists tab (Alt/Cmd+4; Settings moves to 5) with the person's
  playlists also listed in the sidebar: create, rename, reorder by drag,
  remove songs, delete, play, shuffle, download what's missing, export,
  and import a file from another device.
- Any song can be played in full: album and playlist rows get a play
  button that streams on the desktop (the web keeps its previews), and
  "Save as playlist" turns an opened album or playlist into your own.
- A song menu on every row: play next, add to queue, add to a playlist
  or a new one.
- A queue panel: what plays next in play order, drag to reorder, remove,
  jump, clear.
- The player warms up the next streamed song while this one plays, and
  restores the last queue and position on launch, paused.
- Copy in English and Farsi, with the new backend errors worded in both.
Why entries are songs and not files, why they live in a file and not in the webview, why streams are relayed in chunks, and why both stay off on a hosted instance. CONTEXT.md gains the Farsi terms the new copy uses.
Autoplay, on by default and switchable from the queue panel and Settings.
When one song is left, the player asks /api/recommend for songs like the
one playing and appends ten it hasn't queued or played this session,
marked as autoplay's. Seeding from whatever is current lets a long session
drift like a radio rather than circle one artist.

Suggestions come from Deezer — the artist's radio, topped up from related
artists, then the artist's own top tracks — and, when that finds too
little, from YouTube's Mix for the song. The fallback is what makes it work
for Persian artists, whom Deezer often files under an empty Persian-script
entry. Only the mix's first entries are read; paging a whole one took over
a minute. Songs already in the library play from the file.

Songs the person queues go ahead of autoplay's, repeat never triggers it,
and switching it off removes what it queued straight away.
The desktop pins YouTube's player clients to tv,web. They no longer work:
tv answers "The page needs to be reloaded" and web offers no audio, so
every YouTube extraction failed, and with it streaming and downloads. Each
failed song then fell through to SoundCloud, whose catalogue is mostly HLS
and has no single-file stream for the relay, so the person saw "couldn't
find this song" for songs YouTube has.

ytdlp.run() keeps the pin as the first try and retries once with yt-dlp's
own client choice when YouTube refuses it, so neither going stale breaks
anything on its own. Streaming and downloads both go through it.

Streams from uploads with only segmented formats are now fetched whole
into a small cache and served from disk with ranges, instead of failing.
Suggestions are shuffled before they are cut to size, so the song the test looked for was sometimes cut, about one run in three. The filtering is now checked on an uncut list.
Theme is now two choices in Settings (and in the web header and mobile
settings sheet): the mode — light, dark, or match the system — and the
palette — the original lime, which stays the default, or blue. Nothing
changes for anyone who does neither.

Every colour is a token, and each palette and mode is another set of
values for the same tokens, selected by data-palette and data-theme on
<html>. Components were already token-based except for washes mixed from
raw white and black and a dozen dark surfaces spelled as hex; those now
use fg, well, shell and panel, so a white wash doesn't vanish on a white
page. The accent keeps its lime-* name in every palette.

The light accents are deeper than the dark ones — olive and royal blue —
because the accent is also text, and lime or sky blue on white fails
contrast. The theme is applied before the first render. The splash and
the manifest keep the original palette: neither can read a setting.
@alimohammad0816 alimohammad0816 changed the title Feature/playlists and player Playlists, streaming, a queue, autoplay and light/dark themes for the desktop player Oct 15, 2026
A fresh stream paid ~18s retrying the pinned tv,web clients that YouTube
now refuses before falling back to the default. Once the default has
worked where the pin was refused, skip the pin for the rest of the process.

The player warmed only the next song, after a 4s delay. Warm the next two
immediately so a slow resolve finishes before the current song ends.

The frontend Dockerfile accepts NPM_CONFIG_REGISTRY/timeout/retries build
args for slow or filtered networks.
@alimohammad0816
alimohammad0816 force-pushed the feature/playlists-and-player branch from 3bbb5e2 to 066de59 Compare October 17, 2026 11:37
A streamed song has no catalog page to hand to /api/download, so the
player bar had no way to save it. POST /api/download/song starts a job
from the song's metadata, the same way a playlist download does, and the
bar shows queued/done state from the downloads store.
@amiralibg

Copy link
Copy Markdown
Owner

Built this locally on my Mac and the packaged app runs fine, streaming included, so that covers the one thing you couldn't test. Full-length playback and playlists are a big upgrade for the desktop app. Thanks for this.

Two small things for a follow-up, neither blocks the merge. Exporting a playlist silently overwrites a file with the same name in Playlists/. And /api/stream passes source_url to yt-dlp with no auth on the local port, so I'd like to tighten that at some point.

Merging now, it'll go out in the next release.

@amiralibg
amiralibg merged commit ba89cc5 into amiralibg:main Nov 2, 2026
@amiralibg amiralibg mentioned this pull request Nov 2, 2026
@alimohammad0816

Copy link
Copy Markdown
Contributor Author

Thanks for testing it and for merging the PR! I'm glad it's useful.

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.

2 participants