Architecture
MixMusic is one Go program. MyGo draws its
windows in Go, with its ui package: immediate-mode views, which the GPU
draws, without a webview. The windows show the catalogs, the accounts and
the library of the services of internal/app, and the player in Go plays
the queue through MixMusic's own audio engine, or on Spotify's devices.
┌────────────────── the windows (the root package) ───────────────────┐
│ pages, shell, player bar, queue, lyrics, mini player, themes, │
│ plugins' pages and widgets │
└──────┬──────────────────────────┬────────────────────────┬──────────┘
│ App.Services │ the queue │ plugins
┌──────┴──────────────────┐ ┌─────┴──────────────┐ ┌───────┴──────────┐
│ internal/app: Catalog, │ │ internal/player │ │ internal/plugins │
│ Collection, Accounts, │ │ ├ internal/audio │ │ (sobek, a JS │
│ Preferences, Addons, │ │ └ Spotify devices │ │ engine in Go) │
│ System; the desktop │ └────────────────────┘ └──────────────────┘
├─────────┬─────────┬────────┬───────┬────────┬─────────┬─────────────┤
│ youtube │ spotify │ stream │ match │ lyrics │ library │ addons ... │
└─────────┴─────────┴────────┴───────┴────────┴─────────┴─────────────┘
Each part has a file of its own in this folder, named after it, such as
player.md or audio.md. Nothing lists them by hand, so that two changes
never add a line at the same place: GitHub shows the folder's files above
this page, and the site shows each part after it, by name, with their
headings in the page's contents.
A new part gets a file of its own here, and needs a line nowhere else.
Add-ons
internal/addonsvalidates theme and plugin manifests, lists the built-in and installed add-ons, and installs add-ons from the community registry.internal/colorfilereads the colours of pywal'scolors.json, matugen's JSON, MixMusic's matugen template and atheme.json, telling them apart by what they hold;internal/app/colorfile.gofollows the file the settings name with MyGo's file watch, and keeps the colours it read last while the file is half written.internal/pluginsruns the plugins the user enabled, each in a JavaScript runtime of its own, sobek (a fork of goja with ES modules), on a goroutine of its own: their modules,console, timers andfetch, and the API of plugins.md, built inapi.jsover Go natives. What they add to the interface the windows show: pages and widgets as views of elements, which the windows draw in their own styles, and canvases, whose drawing calls the plugin records and the window paints.
The app and the desktop
internal/appgives the windows the services (App.Services), tells them when the settings, the accounts, the add-ons or the updates change (App.Watch), makes the player, and puts MixMusic on the desktop: the tray, media keys, global shortcuts, deep links, notifications, updates.internal/nowplayingshows what the engine plays in the system's media controls, and passes on what they ask: Now Playing on macOS, through MediaPlayer, and an MPRIS player on Linux, served by GIO's GDBus, both called with purego, and the SystemMediaTransportControls of a hidden window on Windows, reached through WinRT without cgo. It downloads nothing:internal/apphands it each cover from the artwork store (controls_cover.go), as its bytes on macOS and Windows, and on Linux as a file in the cache folder'snow-playing/, whose address MPRIS gives the desktop.internal/portalasks the desktop's Background portal, in a Flatpak, to let MixMusic run without a window and to start it at login: the sandbox's own autostart folder is one the desktop never reads.internal/appasks it for Start at login, and for close to the tray. packaging/flatpak holds the Flatpak itself, which may read, read-only, the profiles of the browsers installed from Flathub: the import of a YouTube Music sign-in lists each apart from the same browser installed otherwise (youtube_cookies_flatpak.go). packaging/flathub builds MixMusic from source for Flathub, whose build leaves a mark thatflathub.gotells it by: Flathub updates it, so Settings › About offers no download, and it runs the yt-dlp of its manifest rather than download one.internal/taskbarputs previous, play or pause, and next on the thumbnail of the main window's taskbar button on Windows, through the shell's ITaskbarList3, and takes their clicks in a window procedure in front of MyGo's.internal/keypadtakes Ctrl and the keypad's +, which MyGo gives its windows as no key, so that they zoom in as Ctrl+= does (#251): in a keyboard hook of the window's thread on Windows, and on Linux in a handler of the GTK window's key presses, which GTK runs before the surface's. On macOS, View › Zoom In's ⌘+ takes them (zoomInKey).internal/diagwrites the log,mixmusic.login the logs folder, rotated at a megabyte and without the cookies, hashes and tokens a line may hold, and has Go write a crash tocrash.logthere, which the next start keeps ascrash-<time>.logand offers to report.
The audio engine
internal/audiois MixMusic's audio engine: two decks, each a goroutine that reads a track, Opus in WebM withwebmandopus, AAC in MP4 withmp4,aacandresample, or the AAC of a live stream's packed audio, its ID3 tags passed over, which has no length and can't seek, or a file of the music on this computer (pcm.go): FLAC withflac, Opus and Vorbis in Ogg withogg,opusand jfreymuth/vorbis, MP3 with go-mp3, the delay and padding of its LAME tag dropped, and WAV, each converted to 48 kHz and mixed down to stereo, as its first bytes tell, and decodes it into a ring the audio thread plays from, at 48 kHz, each turning a track louder than -14 LKFS down to it, from the loudness itsMeasuredsource tells (Even loudness), and the preamp, equalizer, levelling compressor, volume and analyser of the web player MixMusic had, ported from Chromium's Web Audio, which they match sample for sample. A deck plays its track faster or slower at its own pitch by WSOLA (stretch.go), the waveform-similarity overlap-add of Verhelst and Roelands, between its ring and the mix; at 1× it plays the ring as it is, as a live stream always plays, with nothing ahead of its live edge to play faster. The six channels of 5.1 surround stretch alike, each window found on the mix of them all.MIXMUSIC_STRETCH_WAV=<dir> go test ./internal/audio -run StretchListenwrites the test files at each speed, to listen to.MIXMUSIC_LIVE=1 go test ./internal/audio -run Liveplays songs of both codecs, and a 24/7 live stream, as the app finds them on YouTube, against ffmpeg's decode.Probetells whether a deck plays a file, and how long, reading little of it, for the index of the music on this computer; with ffmpeg on the PATH, the tests play a file of each kind it makes against its decode.internal/audio/outputplays it on the system's output, without cgo: through WASAPI on Windows, an Audio Queue on macOS, and PulseAudio, which PipeWire serves too, or ALSA on Linux. On Windows it also hears what a process and its children play, through WASAPI's process loopback, and on macOS 14.2 and later what a process plays, through a Core Audio process tap:internal/app's listener hears Spotify there, in MixMusic's webview, or the Spotify app on Windows, into aTap, the analyser of the engine for audio outside it, which the visualizer and the plugins' analyser read while Spotify plays. It hears only while they read it; on macOS, which asks the user once, the tap is of WebKit's GPU process (Spotify here).
Capture mode
MIXMUSIC_CAPTURE=<dir> screenshots pages of the main window, and
MIXMUSIC_CAPTURE_QUIT=1 quits once they are saved:
MIXMUSIC_CAPTURE=shots MIXMUSIC_CAPTURE_QUIT=1 MIXMUSIC_CAPTURE_ROUTES="/|/search?q=jazz|/settings" MIXMUSIC_CAPTURE_SIZE=1440x900 ./MixMusic
MIXMUSIC_CAPTURE_WAIT sets how long each step gets (5s). Steps that
aren't pages act: act:shot:<name> saves the window as it is,
act:mute mutes the player, act:play:<search> plays what a search
finds, act:local:<search> the music on this computer it finds, all of
it without one, act:resume plays the queue kept in the data folder,
act:wait:<duration> waits, act:palette opens the command palette,
act:refresh refreshes the page, as F5 does,
act:panel:<queue|lyrics|none> shows a side panel, act:scroll:<y|end>
scrolls the page, act:theme:<id> wears a theme,
act:colors:<off|pywal|matugen|path> takes the colours from a file, as
Settings › Appearance does, act:mini opens the mini player, which saves
mini.png, act:spotify logs whether MixMusic's own Spotify device
could play, act:title logs the window's title, which the screenshots
leave out, and act:memory and act:heap log the Go heap and save a
profile of it. Nothing plays unless the player is muted,
or MIXMUSIC_SILENT=1 has the audio engine play into no device, which
keeps the visualizer and the lyrics moving without a sound.
MIXMUSIC_CAPTURE_COMMUNITY=<dir> has the marketplace read the community
registry from a copy of community/, for add-ons not on GitHub yet. The
site's screenshots are made this way, from a throwaway profile, at
1536x900 (MIXMUSIC_CAPTURE_SIZE=1552x909: the size includes the
window's invisible borders). go test . -run Render with
MIXMUSIC_RENDER=<dir> draws the pages and themes without a window.
On Linux, linux-app.py starts a build in capture mode, then again as a desktop does, and checks it from outside: its window, its tray icon, the media keys, MPRIS, the zoom keys, and settings.json and colour files edited while it runs. The Linux app workflow runs it on the Nix build and on the release's archive, and keeps the frames, the logs and the D-Bus answers. On a Linux machine with Xvfb, xdotool, ffmpeg, PulseAudio and PyGObject:
xvfb-run -a -s "-screen 0 1600x1000x24" dbus-run-session -- python3 .github/scripts/linux-app.py ./mixmusic out
Casting
MixMusic plays on cast devices, Chromecasts, Google Home and Nest speakers and TVs with Chromecast built in, through their default media receiver, which plays the media at a URL it's given and shows its title, artist and cover on a screen.
internal/castfinds the devices and speaks to them, in Go, with no library: the Cast v2 protocol, from its public descriptions.Browseasks for_googlecast._tcp.local.by mDNS, as a one-shot querier (RFC 6762, 6.7): from a port of its own, to the mDNS group on each of the computer's networks, so the devices answer it directly, and MixMusic listens on no port of mDNS's. The few DNS records it reads, PTR, SRV, TXT and A, it reads by hand (dns.go); a device that answers the first question alone is asked for the rest. The TXT record tells the device's ID, its name (fn) and its model (md); groups of speakers are left out.- A
Sessionconnects over TLS to the device's port, 8009, with the protocol's frames: a 4-byte length, then aCastMessageprotocol buffer, whose seven fieldsmessage.gowrites and reads by hand. It pings the device every 5 s, answers its pings, and takes it for lost after 16 s of silence. It launches the default media receiver,CC1AD845, or joins it where it runs, connects to its transport, and loads, plays, pauses, seeks and stops the media, sets the volume, and hears the device's statuses: of the media, which come as it changes, statuses of media a later load took over from left out, and of the receiver, whose volume shows in MixMusic and whose end, as another app takes the device over, ends the session. Devices have certificates of their own, which aren't checked: the connection is encrypted, and MixMusic doesn't ask the device to prove what it is. casttestis a fake device for tests, a TLS server on the loopback address that speaks the protocol and keeps the messages it gets, and an mDNS responder for it, with DNS and frames of their own, apart from the package's.
- The player (
internal/player/output.go) plays the queue as it does, through aStream;SetOutputgives it the cast device's in the engine's place, on its goroutine, and loads the current stream again there, where it was, playing or paused as it was. On a cast device the volume is the device's, which the settings don't keep, and tracks don't crossfade: it plays one stream at a time, and the player sends the next as one ends. Spotify tracks that play on a Spotify device stay there. - The app (
internal/app/cast*.go) looks for devices a few seconds after start, then every 45 s and as the cast menu opens, and lists those seen in the last 3 minutes.castOutturns the player's stream commands into the session's, one after another beside the player's goroutine, and the device's statuses into the engine's events: the position moves on between them, and the device is asked where it is every 5 s. The engine's events go nowhere while casting. - The audio comes from MixMusic: the addresses the player plays are
MixMusic's own, which no device can open, and YouTube's streams need the
proxy, which fetches them in chunks, waits for YouTube to serve them, and
serves downloads and the cache first.
castServerserves the proxy's/yt/paths, only while casting, at a port of its own on the address that reaches the device, to that device's address alone, under a random 128-bit token. A load the device fails without fetching anything is told as a firewall's doing.
Not yet: live streams, gapless playback through the receiver's own queue, lyrics on screens, groups of speakers, a receiver of MixMusic's own, and video.
Containers and codecs
webmreads the audio of WebM files, as YouTube serves it: the packets of the first audio track with their times, and seeking by the Cues, reading a file played from its start straight through.opusdecodes Opus: a pure Go port of libopus 1.6.1's decoder (celt,silkand the range decoderrangedec, inopus/internal), which decodes the test vectors of RFC 8251 as libopus does, bit for bit.mp4reads the audio of MP4 files, as YouTube serves its AAC: the packets of the first audio track with their times, and the codec delay of the edit list, from progressive files and fragmented ones, seeking by the sample table, thesidxor themoofboxes.aacdecodes AAC-LC, in mono or stereo, with all of its tools, and reads the headers of ADTS frames: a pure Go decoder derived from SharpJaad, the C# port of JAAD, under SharpJaad's MIT license, which decodes YouTube's AAC within about 136 dB of ffmpeg's decoder, but for the few frames whose channels share their noise, as the standard has it and ffmpeg's decoder doesn't.resampleconverts it from its rate, 44.1 kHz for YouTube, to the engine's 48 kHz, with a polyphase filter of Kaiser-windowed sincs.flacdecodes FLAC, written from RFC 9639: the STREAMINFO and SEEKTABLE, and every kind of frame and subframe, checking their CRCs and passing over the frames that fail them, seeking by the SEEKTABLE's points, else by halving the file, searching for frames by their sync codes and headers. Its tests decode FFmpeg's files to the MD5 of their STREAMINFO, and, with ffmpeg on the PATH, encode sound at each of its levels and ways of coding stereo and decode it back as it was.oggreads the packets of an Ogg file's first logical bitstream, RFC 3533, with the granule positions of their pages, checking their CRCs, and seeks by them, as the Opus and Vorbis of files on a computer are kept.ac3decodes AC-3 and E-AC-3, the 5.1 surround of YouTube's formats 380 and 328: a pure Go decoder written from ATSC A/52 and its Annex E, of every audio coding mode and tool, spectral extension and AHT among them, into six channels, with the stereo downmix the stream's own mix levels ask for. It decodes streams of ffmpeg's encoders as ffmpeg's decoder does to about 110 dB, and its tests edit those streams, or write them again, to use the tools the encoders don't.
Data
Everything lives in the user's data folder (%AppData%\MixMusic on
Windows, ~/Library/Application Support/MixMusic on macOS,
~/.config/MixMusic on Linux): settings.json, library.json,
queue.json, positions.json (where the user left podcasts' episodes),
local-files.json (the index of the music on the computer),
addons/, bin/ (yt-dlp), the YouTube session
(youtube-session.bin, encrypted with a key of the system's credential
store, which also keeps the Spotify token and the Last.fm connection), the
scrobbles still to send, and updater.json, the update preferences. On
Windows, a secret the Credential Manager can't keep, as in a logon session
without credentials, is sealed for the user with DPAPI in
spotify-token.dpapi, lastfm.dpapi or youtube-session-key.dpapi; on
Linux without a Secret Service, the secrets and the session are in plain
files only the user can read (spotify-token.json, lastfm.json,
youtube-session.json). secretStore (internal/app/secrets.go) moves a
secret found in a file into the credential store once it can keep it.
settings.json may be edited while MixMusic runs: App.watchSettings
watches it (mygo.WatchFiles, or a look every two seconds where it can't),
and App.ReloadSettings applies an edit as Settings applies a change, then
tells the windows (App.Watch). A file with a mistake is told of after a
second, and kept as settings.json.broken if MixMusic quits before it is
fixed; a settings.json that is a link stays one. The
cache folder holds audio, the covers shown (artwork/), the covers of
the music on the computer (local-art/), on Linux the
cover the desktop's media controls show (now-playing/), matches, how loud
YouTube measured the tracks played (loudness.json), the videos YouTube
labels as made with AI, for 30 days (ai-labels.json), the releases of
the artists followed (releases.json), the dictionary of
the readings of kanji romaji downloads (romaji/), and yt-dlp's cache,
and the logs folder the log and the crash reports. The downloads are in a
folder of their own, which the user can move in Settings › Playback
(downloadsFolder in settings.json): by default
%LocalAppData%\MixMusic\downloads on Windows,
~/Library/Application Support/MixMusic/downloads on macOS, and
$XDG_DATA_HOME/MixMusic/downloads (~/.local/share/MixMusic/downloads)
on Linux: never a folder the system treats as a cache (~/Library/Caches,
~/.cache), whose files cleaning tools delete, nor the Windows folder
that roams. MIXMUSIC_DATA_DIR moves all four into one folder,
for a portable copy or a test run.
MIXMUSIC_YTDLP_ONLY=1 has yt-dlp find every stream, as before MixMusic
asked YouTube's player API, to time one against the other or tell which
fails: the log tells which streams took over a second to find, and which
tracks over two to start.
Following artists
The artists the user follows, on their services or in MixMusic, and what they put out lately, for Home and Library › Artists.
- Follows. Signed in to an artist's service, Follow follows them there,
as before:
Collection.SetFollowingsubscribes on YouTube Music, or saves the artist to the Spotify library. Signed out,Collection.SetArtistFollowedkeeps them inlibrary.json(library.FollowedArtist: their ID, name, artwork and link), as the shows followed are kept. Nothing moves one to the other: signing in later follows nothing on the service. The page says Following for either (followsArtistinfollow.go): its ownFollowing, signed in, or else the list of the artists followed there, as read. Stopping stops in both, where the user follows. The service's list, as fetched, takes the change in place (followedOn), as the podcasts' does: the service may answer a read as it was a moment before. Tests replacefollowOnService. - Releases.
Catalog.NewReleasesgathers the artists followed (followedArtists): MixMusic's, then Spotify's (/me/following) and YouTube Music's (FEmusic_library_corpus_artists) where the user signed in, each once. A look (releaseBook.look) asks for the releases of the artists due: never asked, read more thanreleasesFresh(12 hours) ago, or failed more thanreleasesRetry(an hour) ago; at mostreleasesPerLook(30) of them, those never asked first, then those asked longest ago,releasesAtOnce(3) at a time. YouTube Music's come from the artist's page (youtube.Client.Artist): its Albums and Singles & EPs, ten of each, which tell only their years. Spotify's come from/artists/{id}/albums, the albums then the singles,limit10, the most it takes since February 2026, withrelease_dateas far asrelease_date_precisiongoes (spotify.Client.ArtistReleases). Neither gives a feed of the releases of the artists followed: YouTube Music's New releases are everyone's, and Spotify removed/browse/new-releasesin February 2026. An artist that fails keeps what was read before; one the look's time ran out on isn't counted as failed. - The file.
releases.json, in the cache folder, keeps for each artist when it was asked and when it answered, and their releases of the last year. A look forgets the artists no longer followed, but those of a service whose list didn't read; one the user stops following from MixMusic goes at once (releaseBook.forget). - New (
newReleases):media.Release.SpanreadsReleased, a day, a month or a year, as the first and the last day the release may have come out on. A release is new while its last day is withinreleasesNewFor(8 weeks) and its first day has come: one known by its year alone is new all that year, and 8 weeks into the next. At mostreleasesPerArtist(4) of an artist show, their newest, andreleasesShown(30) in all, each album once. They come by their first days, the newest first, then by their places among their artists' new releases, then by their artists' names, so that the list known before a look and the one after it come in the same order. - The windows (
releases.go). Home, after the user's own shelves, and Library › Artists, first, showreleasesShelf. Until this run's look comes (thefetchof"releases"), it showsKnownReleases, what the file holds, without asking anything; a refresh asks again. Following asks again; stopping drops the artist's releases from both at once. A release's tile is its album's, with the day it came out, or its year, where an album's year goes.
The library and downloads
internal/librarykeeps liked and disliked songs, mixes, the history and stats, and the artists followed in MixMusic (Following artists), inlibrary.json.internal/localfileskeeps the index of the music on the user's computer,local-files.json: the music files of the folders the settings name (musicFolders), by path, with their size, time of change and tags, whichinternal/tagsreads (ID3v2 and ID3v1, MP4'silst, Vorbis comments and FLAC's PICTURE, WAV's INFO), and whether the engine plays them, asaudio.Probetells. A scan, at the start, when the folders change and on demand, reads only the files new or changed by their size or time of change, four at a time, drops those gone, and keeps those of a folder that can't be read. Covers, held or beside the files, go to the cache'slocal-art, by their hash. A track's ID is its file's path in base64 (file:<path>), so that one in a mix or the queue plays as long as its file is there; the player plays it from the file (prepareFile), and it goes to no service: the matcher refuses it (match.ErrLocal), no radio starts from it, and a cast device isn't handed it. One whose file is gone is missing until a scan.internal/downloadskeeps the tracks the user downloads, apart from the offline cache: a folder of their audio (audio/<video id>.webm, or.m4afor a track YouTube has only in AAC) and covers (art/), with anindex.jsonof what it holds, its paths relative to the folder, so that it moves whole. A Spotify track downloads as its recording on YouTube Music, and tracks of one recording share a file. Two workers download what waits, oldest first, a few seconds apart, throughProxy.Download: the stream's chunks, its URL found again when YouTube refuses it, connecting again from the byte it reached, into a.partfile that a later run goes on from when YouTube sends the same file (its itag, length and last change). A track the offline cache holds comes from the cache. Each file is read as the engine reads it, Opus in WebM or AAC-LC in MP4, before it's kept. Downloads wait while paused, while the network is gone, on a metered connection unless the settings allow it, for 15 minutes once YouTube turns one down, and on a full disk; a track YouTube won't send fails with why, and others try again after 30 seconds, 2 minutes and 10. A live stream has no file to keep: the index keeps the videos playback or a download found live, whose tracks it leaves out of their collections and refuses, until one plays as a video again. A collection downloaded keeps the tracks taken out of it since, until its download is removed: MixMusic can't tell them from those a service stopped listing, as a video made private. Its page brings it up to date (syncDownloadindownloadsync.go), once for each of the page's answers:SyncCollectiondownloads the tracks its download never held, and leaves those it held as they are, so that one that failed or whose download was removed isn't asked for again. As many tracks as Download asks before wait for the head's button, and the settingdownloadAddedturns it off. Nothing happens in the background: knowing what was added to a playlist means reading it whole. The proxy serves downloads before the cache (stream.Store), and the windows read a snapshot of what they hold in each frame, a map lookup a track row. Downloads hold stereo audio only: the proxy serves 5.1 surround audio from the cache or YouTube, and without the network it fails, for the player to play the download's stereo audio instead.
The player around the notch
On a MacBook whose screen has a notch, MixMusic widens the camera housing
with what plays, and a panel grows down from it with the player and the
lyrics (#46). It is a window of its own, as the mini player is: notch.go
opens it with a shell of its own, and keeps its state in a notch.
- When it shows (
internal/app/notch.go): whilenotchPlayeris on, the main window is open, in the tray too, and a track is loaded.Options.Notchopens it, and returns nil where no screen has a housing: other Macs, external displays, a closed lid, Windows and Linux. The app follows the player's events, the settings (from settings.json too) andmygo.Screen.OnDisplaysChanged, which closes it and opens it again at the housing's new place. It closes with the main window, so it alone never keeps MixMusic running.System.OpenMainWindowshows the main window, at a page throughnavigateTo, and leaves the other windows open. - The housing (
notchplace.go,notch_darwin.go): eachNSScreenthat answersauxiliaryTopLeftAreagives the widths of the menu bar's parts beside the housing, their height and the gap between them;housingOfmatches it to themygo.Displayof the sameNSScreenNumberthat isInternal, and places it in MyGo's coordinates. The compact window and the open one are sized from it, in points: they don't follow the zoom. - The window: MyGo's
Panel,Frameless,Transparent, without a shadow, shown on every Space withShowInactive. AppKit's part, through purego, sets the nonactivating panel's style alone, asFramelesskeeps a titled one that AppKit holds below the menu bar;NSStatusWindowLevel;canHideoff; no animation of its own; andbecomesKeyOnlyIfNeededwhile compact, so that a click on it leaves the keyboard where it was. MyGo tracks the pointer only while the app is active, which the panel never makes MixMusic:notch_darwin.goadds a tracking area that is always on, for hover and the close countdown. - Motion: the window keeps the open size while the panel moves, and
the root's
Drawpaints the black, whose sizeAnimateTomoves; the contents fade withAnimateColor. Under Reduce Motion the size goes at once, so the panel fades in place instead, at its open size. Once both settle, the window takes the compact size, if the panel wasn't opened since. The bars are painted fromp.Now()at 30 frames a second, and rest while paused or under Reduce Motion. - Closing and the keyboard (
notchclose.go): a click on either side, Escape, or the pointer away two seconds (countdown), which a drag of the progress bar or the keyboard's focus on a control holds off. Open, the window takes the keyboard (makeKeyWindow); closing gives it back by ordering the window out and straight back in, asresignKeyWindowis only a notification. Monitors of presses give it back on a click outside, which goes on to what it was on, and count the presses the window gets:keyHandBackwaits for the last one's release, as giving the keyboard back between a press and its release cancels MyGo's click. - Notifications: while the open panel has the keyboard, the track
that starts shows no notification, as while the main window or the mini
player is in front (
internal/app/infront.go). A like or a dislike still shows one, as the panel shows neither. - Lyrics come from
Catalog.Lyrics, which keeps them, throughfetch: the line sung now, with those before and after it. An episode shows the chapter that plays in their place, from its details (episodedetails.go). - The setting's row is
settings_notch.go, in Settings › On the desktop, only where a housing was found (hasNotch, found again as the displays change).
The tests draw it with a made-up housing (notch_test.go); what only a
Mac with a notch shows, its place over the menu bar, the keyboard going
back and the items beside it, is checked by hand.
The player
internal/playerplays the queue: one goroutine owns the queue and what plays, and the network calls run beside it. It plays YouTube Music, and Spotify tracks matched to their recording, through the engine, and Spotify tracks on a Spotify device: a Spotify Connect device, or MixMusic itself, as below. It shuffles, repeats, crossfades, starts radios when the queue runs out, passes over the songs the user dislikes but those they pick, counts plays and keeps up to 500 tracks of the queue, around the one playing, inqueue.json, as it changes and as MixMusic quits. A track that can't play for want of the network gives way at once to the next that plays without it, a download or a track the offline cache holds, as the app tells; once none is left, it waits for the network where it was, and plays on once it answers. The player pauses as the computer sleeps. As it moves on by itself it passes over, on one path (skip.go), the entries the settings skip, but those the user picks: songs the user dislikes, and songs YouTube labels as made with AI (labels.go). A track whose label the app doesn't know is checked before it's announced, beside the search for its stream, and the next track before it's preloaded or crossfaded into.internal/onlinetells the player whether the network answers: a check of YouTube's and Spotify's addresses over HTTPS, shared by its callers, which audio coming from YouTube answers too, and a wait for the network, with checks further apart the longer it lasts. The system's word on the network, through MyGo, only has it check at once.
Podcasts
Shows and their episodes, on YouTube Music and Spotify: found by search, opened on a page of their own, followed in the Library, and played where the user left them.
internal/media. A show is aPodcast, with a page of its episodes andMore, which reads the next. An episode is aTrackwith anEpisode: its show, the day it came out, and where the user left it on Spotify. Its artists name its show, so that the player bar, the tray, notifications and the media controls name it too. Shelves hold shows as items ofItemPodcast.- YouTube Music (
internal/youtube/podcast.go). Search has a podcasts and an episodes filter. A show is the browse pageMPSPand the playlist of its episodes: its head is an album's, and its episodes aremusicMultiRowListItemRendererrows, which play a video of the typeMUSIC_VIDEO_TYPE_PODCAST_EPISODE. Their days ("8h ago", "Oct 26", "Dec 29, 2025") and lengths ("2 hr 18 min") are read in English, as MixMusic always asks. Pages of about a hundred more come from browse's continuation. An episode plays as any video, through the resolver. The shows the user saved areFEmusic_library_non_music_audio_list, and saving one likes its playlist, as for a playlist. - Spotify (
internal/spotify/podcast.go). Search asks forshowandepisode, 10 of each, the most since February 2026. A show isGET /shows/{id}, its pages of 50GET /shows/{id}/episodesby offset, the shows savedGET /me/shows, and saving is/me/librarywithspotify:show:URIs.resume_pointcomes with theuser-read-playback-positionscope, which sign-ins made before it lack. Shows have no publisher for apps in development mode. Episodes play only from Spotify, asspotify:episode:URIs, on Spotify Connect or the Web Playback SDK:PrepareStreamrefuses them, as YouTube Music has no recording of them.GET /me/playerasks withadditional_types, or Spotify would tell no item while an episode plays, and the player would take the device for playing something else. - Where episodes were left (
internal/library/positions.go). The player's reports, each second, keep the place of the episode that plays, in memory, written topositions.jsononce they rest 10 seconds and as MixMusic quits, for the latest 500 episodes. Under 15 seconds in, nothing is kept; within 2 minutes of the end, or 3% of a short episode, it counts as played. An episode that becomes current plays from 5 seconds before its place, through the player's optionalResumer, or from its service's while MixMusic has none: Spotify'sresume_point, or YouTube Music's, as below. Moving within the current episode, as Previous does, never resumes. - Where YouTube Music saw them (
internal/youtube/progress.go,internal/library/resume.go,internal/app/youtube_watchtime.go). Signed in, a row of a show's episodes tells how far the user got, asplaybackProgressPercentageof its length, and "Played" in itsplaybackProgressTextonce they finished it: the episode keeps it asResumeMsandPlayed, withToldAt, when it was read. That place outranks MixMusic's own when it is newer: it changed since MixMusic kept its own, whose track keeps the place YouTube Music told then, and was read after. When MixMusic never saw it, the place further along wins. One within 30 seconds and a percent of MixMusic's own keeps MixMusic's, exact to the second. Once an episode's play counts, the play goes to the history with ayoutube.Watch, which reports the listening to the player response'svideostatsWatchtimeUrlas YouTube's player does, with the same playback nonce: what was heard since it started, then on the schedule the response tells, 10, 20 and 30 seconds after it started and every 40 after, a few seconds early at random as the player has them; as it pauses and plays again; and a last time,final=1, as another track plays or none does. The history setting governs it as it does the plays: off, or signed out, nothing goes. - The player. Episodes never crossfade, the next episode's audio isn't fetched ahead, and the queue that ends on an episode stops rather than start a radio. Episodes don't scrobble, and the stats leave them out: the history keeps them.
- Speed. Episodes play at a speed of their own,
podcastSpeed, and songs atsongSpeed: the player'sspeedOfpicks by the track that becomes current. The speed button by the player, the mini player's menu, the command palette and the rate the media controls set change the speed of the kind that plays (speed.go). Spotify's players, and live streams, keep their own speed: the button is dimmed for them. - Details (
episodedetails.go). Where a song shows its lyrics, in the side panel, on the now playing page and in the notch, an episode shows its details, whichCatalog.EpisodeDetailsasks for the first time they show, then keeps:media.EpisodeDetails, its show, views, day and length, and its description line by line, in runs of plain text, links and times. YouTube Music's come from the page of the Details tab its player has where a song has Lyrics, browsed byMPPEand the video ID, whose runs mark the links and the times; the episode's own page,MPED, has the same description, and the player's answer none. Spotify's come fromGET /episodes/{id}, whose plaindescriptionmedia.ParseDescriptionreads, finding web addresses and times as 12:34 or 1:02:03. Lines that start or end with a time are chapters (media.ChaptersOf): the panel lights the one that plays, and the notch shows it. Links open only web pages, http and https, and times past the episode's length don't seek. - Follows.
library.jsonkeeps the shows the user follows, without their episodes, signed in or not; following one saves it on its service too, where the user signed in. Signed in, a show saved there, as from another profile or device, counts as followed on its page: the page tells (Saved), or else the shows saved there do, the list Library › Podcasts shows, asked for once. Stopping takes it out there, and fails when the service refuses one followed only there. The list takes the change in place rather than be read again, which the service may answer as it was a moment before. - The windows (
podcasts.go)./podcast/<id>is a show's page, the search's podcasts pivot asks only once it shows, and Library › Podcasts lists the episodes in progress, the shows followed, and those saved on each service. Episode rows are built only near the view.
The services
internal/mediadefines what every service shares: tracks, albums, artists, playlists, shelves. IDs carry their service:yt:<video id>,sp:<track id>,mx:<mix id>. The services give a playlist with its first page of tracks, andMore, the token of the next;Playlist.ReadOnreads on a page at a time, asks again after 1, 2 and 4 seconds for a page that fails, and stops atMaxTracks, 20,000, past which the playlist isCut.Catalog.Playlistreads one whole, for what takes all of it, as a download or a plugin.internal/youtubereads YouTube Music through the API its website uses: search, suggestions, the home and explore pages, charts, moods and genres, albums, artists, playlists, related tracks, radios and plain lyrics. Signed in with the cookies of the user's session, which it signs its requests with as the website does, it reads and changes their library: liked songs, playlists, albums, subscriptions, likes, dislikes and saves, and it deletes the playlists the user made, which it tells from those they saved by the menus YouTube sends with them. It lists the Google accounts of the session and their brand channels, from YouTube's website, and uses a brand channel by its page ID: one whose page ID the list doesn't tell is listed asUnswitchable, as without it the account itself would be used. Once the app switches to a brand channel, it goes back if YouTube Music answers as the Google account itself, as the list names it; any other answer keeps the switch. It also reads whether YouTube labels a video as made with AI, from the answer of YouTube's own website, asked signed out with a field mask (MadeWithAI): nothing of YouTube Music carries the label. Its parsers are tested against recorded responses intestdata/, and against the live service withMIXMUSIC_LIVE=1 go test ./internal/youtube -run Live(MIXMUSIC_YT_COOKIESadds a signed-in account, andMIXMUSIC_YT_WRITE=1lets it make and delete a playlist there, and dislike a song and take the dislike back).internal/spotifysigns in with PKCE on a loopback callback, refreshes tokens, reads the Web API (search, albums, artists, playlists, the library), and controls Spotify Connect devices.internal/sysproxypicks the proxy of every request MixMusic makes, as theProxyofhttp.DefaultTransport, which the clients use or clone: the environment's (HTTPS_PROXYand the like) when it names one; on Windows, failing that, the one of Windows' settings, which browsers, WebView2 and yt-dlp use too. Automatic setup comes first, detection or a setup script, which WinHTTP answers for each site and which is remembered for 5 minutes, failures too; then the proxy server, unless the bypass list names the address. Loopback addresses always connect directly. It reads the settings again when their registry key changes, as VPN and proxy apps turn them on and off, logs what MixMusic connects through, without user names or passwords, and gives yt-dlp a--proxywhere yt-dlp wouldn't find that proxy itself. A package that cloneshttp.DefaultTransportas it initializes imports it, asinternal/onlinedoes. macOS and Linux keep the environment's proxy alone.internal/matchfinds a Spotify track's recording on YouTube Music, by title, artists and duration, for playback without a Spotify device. It also finds the song of a music video, by its title and artists, which the video's radio starts from: YouTube Music makes the radio of a video out of videos.lyricsfinds synced lyrics on Unison and LRCLIB, reads TTML and LRC, and translates and romanizes lyrics on Unison.internal/romajiwrites Japanese lyrics in Hepburn romaji on the computer, as Unison gives none for Japanese: kana from a table, and kanji by the readings that kagome's tokenizer finds in its IPA dictionary, word by word. The dictionary isn't built in, which would grow the app by 11.7 MB:Dictionarydownloads it from kagome-dict's module on GitHub the first time romaji is on for Japanese lyrics, checks it against the SHA-256 written in the package, keeps it in the cache'sromaji/, and checks it again each time it loads. A download that fails or isn't the dictionary goes, and is tried again after 30 seconds, twice as long after each failure in a row, up to 10 minutes. Until it is there, the lines' kana are in romaji and their kanji stay as they are. Loaded, the dictionary takes some 89 MB of memory: it loads in about 0.4 s when lines need it, and goes 5 minutes after they last did. The windows make each track's romaji once, with its lyrics. Its tests read the dictionary from the Go module cache, wherego mod download github.com/ikawaha/kagome-dict/ipa@v1.2.6puts it.internal/romanizewrites lyrics in the other scripts in Latin letters on the computer, each run of a script of a line by the standard of its language, as the package's doc lists them: the Revised Romanization for Korean, with its sound changes; pinyin for Chinese; the systems of each language of Cyrillic, told by its letters when the lyrics' language isn't known; ELOT 743 for Greek; Hunterian for the Brahmic scripts, by one engine over their aligned blocks; RTGS for Thai, by a syllabifier of rules; BGN/PCGN for Lao and Burmese and the UN's system for Khmer, each parted into syllables by its script's own structure (syllables.goplaces them); EWTS for Tibetan; and the vowel points of Hebrew and Arabic where they're written.Wordsgives each word of a line synced word by word its part of the line in Latin letters. Chinese readspinyin.bin, 177 KB built in, whichtools/pinyindatamakes from Unihan and mozillazg's pinyin-data and phrase-pinyin-data: each character's reading, and only the words read otherwise than their characters, 8,799 of 47,152. It is read the first time Chinese is romanized, in 4 ms, and holds 1.3 MB. The windows make each track's romanization once, with its lyrics (lyricsromanize.go), and ask Unison for none, but Arabic's, whose romanization by Google has the vowels Arabic doesn't write.internal/lastfmsigns in to Last.fm with the user's API account and scrobbles by Last.fm's rules, keeping what fails offline for later.internal/discordshows what plays on the user's Discord profile, over Discord's local pipe or socket.
Settings
internal/storewrites JSON files atomically. A file the user may edit while MixMusic runs,settings.json, it reads again when asked (File.Reload), and before each write, so that it never saves over an edit it hasn't read: it merges the edit in, by top-level keys. While the file doesn't decode, it leaves it as it is and holds its writes.internal/settingsdefines the settings, keeps them in range, and readssettings.json(Decode), at start and on each edit alike.- A struct for each section.
Settingsembeds one struct for each part of the settings, such asAppearance,PlaybackandDesktop, each in a file of its own with its defaults and its clean-up. JSON flattens embedded structs, sosettings.jsonkeeps one flat object, in the order the sections are embedded.testdata/default.jsonandtestdata/full.jsonpin how it writes. - The settings page builds each tab from the parts that registered for
it with
addSettings, from their features' files, in the order they asked for.
Spotify in MixMusic on each system
"In MixMusic, from Spotify" plays Spotify tracks with Spotify's Web
Playback SDK, in the hidden window of public/spotify.html
(its web view). Spotify encrypts its audio, and the SDK
plays only where the web view has a decryption module (a CDM) it takes.
Here is what each system's web view gives it, as tried for #295 in
November 2026, and what playing there takes. MixMusic plays Spotify
itself on Windows and macOS.
| System | Its web view | Spotify in MixMusic |
|---|---|---|
| Windows | WebView2: Chromium, with Widevine | plays; the default |
| macOS | WKWebView: WebKit, with FairPlay | plays; the default |
| Linux | WebKitGTK: WebKit, without EME | not in its web view; costly with an embedded Chromium |
What the SDK asks for
- Its frame. The SDK plays in an iframe of
https://sdk.scdn.co/embedded/index.html(allow="encrypted-media; autoplay"). It looks for Widevine (com.widevine.alpha), then PlayReady, then FairPlay (com.apple.fps.1_0), through EME or WebKit's olderWebKitMediaKeys. - Where none answers, it fails with
initialization_error("Failed to initialize player") at once, before it asks for a token. - Widevine at software robustness (
SW_SECURE_CRYPTO, level L3) is enough. That is what desktop Chrome has on Linux. - On macOS, Widevine needs a host signed for Google's Verified Media Path (VMP). Spotify played about a second of each song, then stopped, in a castlabs Electron build with Widevine, until the build was signed (castlabs/electron-releases#193). FairPlay is the system's own and needs no signing.
macOS
MixMusic plays Spotify itself on macOS too, with the FairPlay of its web view (#295). Two probes on the test Mac found what that takes. Both ran on macOS 26.5 (Apple silicon), with MyGo 0.7.3's WKWebView.
Without a sign-in. The probe used a hidden window of
public/spotify.html, at mygo://localhost, with a script in every frame
that noted what the SDK's frame asked of EME. The SDK got a made-up token.
What it found:
- EME.
requestMediaKeySystemAccessgrantscom.apple.fpsandcom.apple.fps.1_0, forcenc,sinfandskdinit data, andcreateMediaKeysworks. ClearKey is granted too. Widevine and PlayReady aren't. - The SDK starts.
- Its frame is a secure context. It finds FairPlay
(
WebKitMediaKeys.isTypeSupported("com.apple.fps.1_0", "audio/mp4")), makesWebKitMediaKeys("com.apple.fps.1_0"), and fetches Spotify's FairPlay certificate (spclient.wg.spotify.com/fairplay-license/v1/application-certificate, 200). - Then it asks for the token. The made-up one ends in
authentication_error, notinitialization_error. - The same happens from an
https://page, and with the web view's own user agent, which names no Safari. WKWebView needs no other scheme, origin or setting.
- Its frame is a secure context. It finds FairPlay
(
- Media Source Extensions play AAC in MP4 (
mp4a.40.2,mp4a.40.5) and Opus in WebM, which Spotify streams. They don't play FLAC in MP4. - The hidden window plays sound without a gesture, with or without
MyGo's
AutoplayAllow. That was tested with digital silence.- Its page is hidden (
document.visibilityState), unlike WebView2's. - WebKit slows a hidden page's timers to once a second unless
BackgroundThrottlingisThrottlingOff.
- Its page is hidden (
Signed in. A build of the change, with the maintainer's own Spotify sign-in, played one track on MixMusic's device at volume 0. The page's gain was set to 0 before the player was made. What it found:
- The device gets ready.
mm.supported()is true, andreadygives a device ID 2.3 s after the player connects. - The track plays.
- The Web API takes the play in 0.5 s.
- The track sounds 3.2 s after that: the player no longer loads, and isn't paused.
- Its position moves a second each second, from 6 ms to 10.6 s in 11 s, at volume 0, on the track asked for.
- The sound comes out of WebKit's GPU process. Core Audio's process list
showed
com.apple.WebKit.GPUrunning output from when the track sounded until its pause. No other process did. - Not checked on the Mac: the gapless list, a seek near a track's end, and the hush (#184, #187, #273). They run in the same page as on Windows.
What it took:
- The gate.
playsSpotifyOnininternal/app/spotifyhere.gotakes Windows and macOS. The settings askapp.SpotifyPlaysHere. - The key system.
canPlayinpublic/spotify.htmltakes FairPlay (com.apple.fps), after Widevine and PlayReady. - The window. On macOS the device's window keeps its page running
while hidden (
herePage:ThrottlingOff), for the SDK's own timers. Its autoplay needs nothing. - The default. In MixMusic is the default of a new install on macOS,
as on Windows (
defaultSpotifyPlayback). An install whose settings.json already hasspotifyPlaybackkeeps it.
The visualizer hears it through a process tap, from macOS 14.2 on
(#304). The listener of internal/app/listen.go feeds the same Tap as
on Windows (the audio engine):
- The process. WKWebView plays media in WebKit's GPU process,
com.apple.WebKit.GPU, one for each app that has web views, with a new PID at each launch. MixMusic's is the one macOS holds MixMusic responsible for (processes_darwin.go):- launchd starts WebKit's processes, so their parent is PID 1, but
libSystem's private
responsibility_get_pid_responsible_for_pidgives the app they work for, as macOS asks permissions of it; - Core Audio's process list (
kAudioHardwarePropertyProcessObjectList) gives each process's PID and bundle ID. - On the test Mac, the GPU process found so was always the one that
WebKit's private
-[WKWebView _gpuProcessIdentifier]names, beside Safari's.
- launchd starts WebKit's processes, so their parent is PID 1, but
libSystem's private
- The tap (
internal/audio/output/tap_darwin.go, with purego, as the rest of the output, without cgo, whichmygo buildturns off):- a
CATapDescriptionof a stereo mixdown of that process, unmuted and private, forAudioHardwareCreateProcessTap; - a private aggregate device of the tap alone, which starts it as it starts;
- an IOProc of the device, on Core Audio's I/O thread, hands each 512 frames to the capture's goroutine, which converts them to 48 kHz when the output device runs at another rate, and gives silence at a stream's pace when the I/O stops, as the loopback of Windows does.
- a
- When. The listener hears only while Spotify plays in MixMusic and the visualizer or a plugin reads the analyser, and for 2 s after the last read. It stops when Spotify pauses, and as MixMusic quits.
- The permission. macOS asks once whether MixMusic may record what
other apps play, with
NSAudioCaptureUsageDescriptionfrommygo.config.ts. A tap hears only once the user allowed it: until they answer, and once they refuse, it runs but hears digital silence, and no call fails.- MixMusic asks first, through TCC's private
TCCAccessRequest(kTCCServiceAudioCapture), and taps once the user answered. The capture waits for the answer. - Not the tap. A tap that has macOS ask itself started only 90 s later on the test Mac, though the user had answered in 5 s: coreaudiod started the tap at the answer, and MixMusic's call returned 90 s later.
- Not the preflight alone.
TCCAccessPreflight(0 allowed, 1 refused, 2 not asked) answered 2 in that process for the rest of its run, though the user had allowed it; a new process was told 0. So MixMusic keepsTCCAccessRequest's answer for the run. - Once refused, a capture fails with
ErrRefused, and the log says why, once a run, as it does of a Windows without process loopback.
- MixMusic asks first, through TCC's private
- Before macOS 14.2 there is no
AudioHardwareCreateProcessTap: the capture fails withErrNoLoopback, and the visualizer stays flat for Spotify.
Tried on the test Mac (macOS 26.5, November 2026), with a muted tap, so that nothing played out loud:
- A tone at -80 dBFS in the page of MixMusic's device, in a build signed with the hardened runtime, as releases are, without entitlements: the analyser's waveform peaked at -80.0 dBFS, and its bars rose.
- Spotify in MixMusic, with the maintainer's sign-in: the tap hears
FairPlay's decrypted audio.
- At volume 0 the tap heard exact zeros.
- At a gain of -80 dB, the song peaked at -90 to -105 dBFS; at -73 dB, about 7 dB higher; at 0 again, zeros.
- Pausing stopped the tap.
- The first tap, as macOS asked at the tap itself, took about 90 s to start, and TCC went on answering 2 in that run: why MixMusic asks first.
- The first run, as MixMusic asks first, with the build of MixMusic's own name that had never been allowed: the tap started as soon as the user clicked Allow, and heard Spotify in that same run, as above.
An embedded Chromium (CEF) with Widevine isn't needed on macOS, and would be costly:
- CEF's minimal build for macOS arm64 is 135 MB to download and 353 MB unpacked.
- Widevine there needs VMP signatures, files that a signing certificate from Google makes. They come with a Widevine agreement with Google, or through castlabs' commercial certification. castlabs' free signing service signs only its own Electron builds.
Linux
The probe ran in GitHub Actions, on a branch of its own since deleted. It used Python's GObject bindings, with WebKitGTK 4.1, as MixMusic does:
- Ubuntu 24.04's WebKitGTK 2.52.6 (
libwebkit2gtk-4.1-0), as the .deb gets it; - the GNOME 51 runtime's WebKitGTK 2.54.0, which the Flatpak runs on.
The page was at an https:// origin, with the same script in every frame
and the same made-up token.
What it found:
- No EME in either.
navigator.requestMediaKeySystemAccessandWebKitMediaKeysare undefined in the page and in the SDK's frame. That stays so with WebKitSettings'enable-encrypted-mediaon. - The SDK fails at once, with
initialization_error("Failed to initialize player") within 150 ms, and logs "Platform does not support navigator.requestMediaKeySystemAccess". - Why. WebKitGTK has EME only when built with
ENABLE_ENCRYPTED_MEDIA. That is off by default, and on in none of the Debian, Ubuntu, Fedora or GNOME runtime builds.- Even built in, WebKit brings only ClearKey.
- Widevine would come through Thunder (OpenCDM), with closed binaries that Google licenses to whoever builds the device.
So MixMusic can't play Spotify itself in its web view on Linux. What remains:
- An embedded Chromium (CEF) with Widevine. This is possible but
costly:
- Size. CEF 156's minimal build for Linux x64 is 333 MB to download
and 1.6 GB unpacked; its
libcef.socomes unstripped. - The CDM. Google's licence forbids redistributing it. CEF downloads it at the first run, about 22 MB, through Chromium's component updater, as Chromium's own builds and Firefox do.
- Codecs. CEF's own builds leave out AAC, which Spotify's streams use. It would take a CEF built with proprietary codecs, and their patent licensing.
- Spotify accepts software Widevine (L3), which is all Chrome has on Linux. Linux has no VMP.
- Packages. The .deb, .rpm and AppImage would carry CEF.
- The Flatpak would download the CDM into the app's folder and show it
to Chromium's sandbox, as zypak's
ZYPAK_EXPOSE_WIDEVINE_PATHdoes. - Flathub accepts only content it may redistribute; the CDM isn't shipped, so it stays out.
- The Flatpak would download the CDM into the app's folder and show it
to Chromium's sandbox, as zypak's
- From Go. CEF is a second web engine beside MyGo's WebKitGTK, with
its own processes and message loop.
- energy (
energye/energy) is the maintained Go binding to CEF; the others aren't maintained, and none documents Widevine.
- energy (
- Cost. Weeks of work, then a Chromium update every month or so, for its security fixes.
- Size. CEF 156's minimal build for Linux x64 is 333 MB to download
and 1.6 GB unpacked; its
- Spotify Soloist, Spotify's own headless client (August 2026), for
Linux, Apple silicon Macs and Windows.
- It shows among the Spotify Connect devices, so MixMusic's "On Spotify Connect" plays on it today, with no Spotify app open. MixMusic could help find and choose it.
- Users download it from Spotify, which forbids redistributing it.
- It starts with an API key of the user's own, made with a Premium account.
- Its builds expire after 90 days.
- MixMusic's visualizer can't hear it on Linux.
- Unofficial clients (librespot, and spotifyd and ncspot, built on
it) speak Spotify's private protocol. They are a known option only, not
a plan:
- They are outside Spotify's Developer Terms, whose IV.2.a.ii forbids reverse-engineering the Spotify Platform.
- They could put the users' accounts and the app's client ID at risk.
- In August 2026 Spotify closed its private endpoints to the client IDs of other apps (librespot-org/librespot#1737).
The streams
internal/streamfinds the audio of a YouTube video through YouTube's player API, asked signed out as YouTube's visionOS app (Direct), else with yt-dlp: its Opus stream, else, of a video YouTube has only in AAC, its AAC-LC one (itag 140), never HE-AAC (itag 139). It keeps the stream until its URL expires, and opens it for the audio engine (Proxy.Open): chunked fetches from YouTube in the background, a new URL when YouTube refuses the old one, a new connection, from the byte it reached, when a download gets no bytes for 15 seconds or its connection drops, and an on-disk cache of tracks played to their end. YouTube lists no 5.1 surround audio to the visionOS app, so the direct route asks as YouTube's app for Samsung's TVs too, at once, which it tells of a video's AC-3 and E-AC-3 (formats 380 and 328), and waits for that answer half a second past the stereo stream's at most. Their URLs need a challenge solved, which yt-dlp does with a JavaScript runtime: it finds the 5.1 audio as it is asked for (Resolver.Surround), once, in some 3 s, and YouTube serves it a few seconds after. Without yt-dlp and a runtime, no 5.1 audio is offered. A live video's audio is an HLS media playlist (RFC 8216) of AAC-LC in packed audio, format 234, or 233 with data saver, whichProxy.OpenLivefollows from near its live edge: it loads the playlist again about once per target duration, fetches each new segment once, and finds the stream again before its URL expires, never caching it. The URL of a track's stream names each of YouTube's servers that hold it: when one doesn't connect within 3 seconds, the TLS handshake included, the stream's requests, and the direct route's check of it, ask the next, as YouTube's player does, and for 10 minutes ask the one that didn't answer last (mediaServers). The player API also tells how loud YouTube measured each video (Stream.Loudness,Direct.Loudness). Both list the streams of a video's picture beside its audio (Stream.Videos), which the now playing page plays (Proxy.Picture,Proxy.ServeVideo): a cluster or fragment at a time, as the index at the start of YouTube's DASH streams tells (parseIndex), since WebView2 hands a page a custom scheme's response only once it is whole, and WebKitGTK plays no media from a custom scheme. Its answers are tested against recorded ones intestdata/, and against YouTube withMIXMUSIC_LIVE=1 go test ./internal/stream -run Live.internal/toolsfinds yt-dlp and a JavaScript runtime, and installs yt-dlp from its releases, checked against their published checksums. On macOS it installs the unpacked build,yt-dlp_macos.zip, intobin/yt-dlp_macos/: the single file starts in 8 s, the unpacked one in 0.2 s (#245). A yt-dlp that comes with MixMusic (Manager.Bundled), as in Flathub's build, runs unless the user chose another, and none is downloaded beside it.
Webviews
Three web pages still show, and only while they're needed:
- Spotify in MixMusic itself (
internal/app/spotifyhere.go): Spotify's Web Playback SDK, which decrypts Spotify's protected audio, in the hidden window ofpublic/spotify.html, one of the app's two pages, which it embeds. It opens at the first Spotify track that plays in MixMusic, and makes MixMusic a Spotify device of its own, which the player plays tracks on through the Web API and follows by asking the page (Page.Eval); the page gets its token fromSpotifyPage.Token, and tellsSpotifyPage.Changedwhen what it plays changes, for the player to ask at once. A track plays with the Spotify tracks the queue moves on to after it, which the device goes on to without a gap, and the player follows it there (internal/player/connect.go). At a track's end, the player gives it up to 2 s to go on before it plays the next track itself: after a seek into a track's last seconds, Spotify stops on it a moment while it loads the next. WebView2 decrypts Spotify's audio with Widevine, and macOS's WKWebView with FairPlay, where the window keeps its page running while hidden (herePage). WebKitGTK has no EME: Spotify in MixMusic on each system tells what each web view gives the SDK. - Google's sign-in to YouTube Music, whose cookies MixMusic keeps.
- The picture of music videos (
video.go,internal/app/video.go), with "Show the video of music videos" on in Settings › Playback: a web view of the main window, on the now playing page, in place of the cover, ofpublic/video.html, the other page of the app. It is made when a picture first shows, and closed a minute after the last one. The page plays the picture, muted, through Media Source Extensions, fetching it from the proxy at the stream scheme, which serves that page alone, and follows the engine's time (Player.Clock), which the window sends it four times a second with the moment it read it. A box over the web view keeps the pointer and the keyboard in the native interface.
The windows
The root package draws the main window and the mini player. Pages fetch
what they show beside the main thread (fetch), and a window's state
belongs to its main thread: what finishes elsewhere comes back through
update.
-
Refresh (F5, ⇧⌘R on macOS,
refresh.go) asks again for the keys the page shown and the side panel fetched in the frame before, asfetchcollects them, and renders the plugins' views again (View.Remount). The page keeps its answers until the new ones come, and keeps them when a refresh fails, which one toast tells. Home's own tile and shelves read the library as it is, and need no refresh. -
Pull to refresh (
pullrefresh.go): the scroll container of the page shown (pageScroll) takes its scrolling first, withHandleInput, before MyGo scrolls anything. A pull starts only on a page at rest at its top: scrolling that came up to the top goes on to its gesture's end without pulling, as Windows and Linux send a fling's momentum as any scrolling, and macOS's, which MyGo marks (Momentum), never pulls. A touchpad pulls by its distance (Precise), a wheel by its notches, three of which go far enough. A gesture ends once no scrolling came for a quarter of a second, or as the fingers leave, which macOS and Linux tell with an event that scrolls nothing; let go past the threshold, the pull runs Refresh. A strip above the page's content, which is keyed so that it keeps its state, opens with the pull. Scrolling sideways, or with a modifier, as Shift+wheel, never pulls, nor does scrolling a scroller inside the page, whose own handler marks it (scrollsInside), as the lyrics of the now playing page do. -
Long playlists (
playlistread.go): a playlist's page, and the songs liked on YouTube Music in the library, show their first page of tracks at once and read the rest beside it, telling how many came, how many could be read when a page failed for good, with a way to try again, and where a list longer than 20,000 was cut. Play and Shuffle take the whole list: pressed while it's read, they wait for it, unless another track started meanwhile. The items of the page's menu that take its tracks, and its download, show once the list is whole. The queue builds only its entries near the view, as it may hold thousands. -
Menus are the system's: MyGo builds one as it opens, and again for the item chosen, never while it shows, so what a menu lists is fetched before it opens. Signed in to YouTube Music, the main window reads the user's playlists there at once (
keepYouTubePlaylists), which a track's "Add to YouTube Music playlist" lists, and keeps them: one made goes in first, and one deleted leaves, without reading the list again (keepPlaylist,dropPlaylist); signing in or out, or choosing another channel, reads it again. -
The top bar and the rows pinned under it (
compacthead.go). The page shown keeps where it scrolled in aui.ScrollState(pageScroll), which the top bar, built before the page, reads in the same frame. The heads of playlists, albums, artists (collectionHead) and the liked songs (likedHead) tell how far down the page their buttons end, as the frame before laid them out; past there, the top bar shows the head small, from the same data: artwork, title, play, shuffle and download, fading in withAnimateTo, at once while the computer asks for less motion. Where the bar is narrow, the download goes into its menu. The search and the sort of a list of tracks (trackTools) stay pinned under the top bar once the page scrolls past them (pinRow): the same element, made absolute at the page's offset over the ground repainted (groundOn), so that the box typed in keeps the focus. A search or a sort there shows the list from its start under them, and the page keeps the room for it. Nothing scans a list for this in a frame. -
Artwork downloads only for what shows in the window, scaled to the size it shows at, and the images shown most recently are kept, up to 48 MB of pixels (
artwork.go). An image that couldn't load tries again after 1 to 2 seconds, then less often, up to every 2 minutes, and at once when another of its host loads; one whose address gives no image waits half an hour. Both windows, the accent, Terminal's tint and the system's media controls read the images throughartwork.Store(internal/artwork), which downloads each once, keeps it on disk as it came, up to 256 MB, and logs a host's failures at most once a minute, without the addresses. -
Themes give their tokens: colours, fonts, radius (
theme.go), and the accent follows the artwork of what plays. A colour file's colours replace the theme's, kept readable (colorfile.go). The layout design the user chose, Blend, Expressive, Crate or Tiles, sets the shapes, the density and the styles of the parts in whatever theme (layout.go). The windows draw the effects of the built-in themes, as their options say (effects.go): Mix's textures, Aurora's drifting lights and frosted dialogs, Sakura's petals, Terminal's scanlines, glow and phosphor artwork. The Material themes make their colours of a seed in HCT (material.go,internal/hct, a port of Material Color Utilities), unless a colour file gives them. -
Lyrics light the line sung now, and its words as they are sung when they have times (
lyricswords.go); in Latin letters made on the computer, under the line or in its place, each word's romanization lights at its word's times (lyricsromanize.go,sungText), where Unison's and romaji's, which have no words, light whole, as below. Lines of TTML synced word by word keep their words' times (lyrics.Line.Words): each span with a begin time is a word, or a syllable, and a line with sung text outside of them has none. The line's text element is clear, and its Draw paints the glyphs of a layout of its own, the same, cut where the song is, the part sung in the accent. The song's time moves on from the position the player last told, at the track's speed, for 1.5 s at most, so the words fill in frames that only paint again; Reduce Motion lights each word whole as it starts. Lines shown in Latin letters in their place light whole. -
The mini player's volume (
minivolume.go) is a button that mutes, and the player bar's slider above it, in a popover that shows while the pointer is over either, a drag holds it or the keyboard's focus is on either, and for 0.4 s after. The wheel over it, as over the player bar's volume, steps the volume (wheelVolume,volume.go). Escape gives the focus back to the button itself: MyGo gives an overlay's focus back to what had it as the overlay opened, which it may take from a tip that showed in its place before.miniRoomkeeps the button's room beside the artwork. -
Deep links and second launches open their pages in the main window (
App.OnNavigate).