Changelog
v1.13.0 (2026-08-26)
Breaking changes in this release
Four changes may require code edits:
library.searchno longer duplicates its row array asitems. The response used to carry the same rows twice, byte-identical, underitemsandtracks. It now sends onlytracks— the keylibrary.queryhas always used. Code readingresult.itemsshould readresult.tracksinstead; every response shape — on older hosts too — carriestracks, so atracks-first reader keeps working against hosts that still send both. This halves the payload of every row-carrying search response.dialog.confirmresolves with{ response }, not{ confirmed }. The declared type was wrong. The host has always returned the zero-based index of the clicked button inbuttonsand never aconfirmedflag, so TypeScript code readingresult.confirmedwas reading a field that was never populated. With the default button set['OK', 'Cancel'],0means confirmed and1means cancelled. The task dialog is created withoutTDF_ALLOW_DIALOG_CANCELLATION, so Escape and the close button do not dismiss it and every result comes from an actual button click;-1appears only on the host's fallback path, when even a plain message box could not be shown.file.*error messages changed, and a rejected path is no longer echoed back. Every hand-rolled error envelope in the namespace now goes through the standard error envelope, and astd::filesystemexception surfaces only its Win32 error number — the exception text and the offending path no longer reach the payload or the host log. A path-security refusal now readsfile.read: path security denied for 'path': Access denied: protected system path: it names the method, the parameter (with its index for array parameters, as initems[2].destination) and the policy reason, so a caller can tell which argument was refused without the host leaking a filesystem location into a payload a page may forward elsewhere. Code that parsedresult.errorfor a path or for specific wording has to switch toresult.codeplusresult.details.- A parameter of the wrong shape now returns
INVALID_PARAMS, notPERMISSION_DENIED. The path-security decorator previously reported a refused path and a malformed argument under the same code. A handler that branched onPERMISSION_DENIEDto catch type errors will stop seeing them there.
The release stays on a minor version, consistent with this project's version axis (see 1.6.0 and 1.12.0). Pin an exact version if you need to upgrade deliberately.
Asynchronous file operations
- New:
file.copyAsync,file.moveAsync,file.deleteAsyncandfile.cancelOp. The work runs on a host worker thread, so copying a large album no longer freezes the UI the way the synchronousfile.copydoes. Each call returns a{ operationId, totalCount }receipt immediately; the outcome arrives onfile:opProgressand is followed by onefile:opComplete. - New events
file:opProgressandfile:opComplete(payload:operationId,op,done,total,results/ the three counts andcancelled). Results are batched, never one event per entry: they accumulate until 64 are pending or 100 ms have passed since the previous batch, so a fast run collapses to roughlyceil(total / 64)events while a slow one keeps a progress signal moving.done / totalis a usable progress fraction, and the final partial batch always arrives beforefile:opComplete. - One result is reported per requested entry, never per file: a directory entry is reported once its whole tree has been walked. Each result echoes
source(anddestination, except fordeleteAsync) exactly as requested,%variable%placeholders included and unexpanded, so it works as a lookup key.statusis'ok'/'skipped'/'failed', withreasondrawn fromalready-exists,not-found,permission,cross-volume,io-errorandcancelled. - Path validation is all-or-nothing. If any entry fails the host's read or write check the whole call is rejected with
PERMISSION_DENIEDand nooperationIdis produced — a partial batch is never dispatched.copyAsynccheckssourceasReadanddestinationasFileWrite;moveAsyncchecks both ends asFileWritebecause a move deletes the source;deleteAsyncchecks every path asFileWrite. At most 8 operations may be in flight process-wide. - Copy and move differ on an existing destination:
copyAsyncmerges a directory into an existing directory (files already there are skipped without being reported individually, and the entry still reportsstatus: 'ok'), whilemoveAsyncreports it asskipped/already-exists.overwrite(defaultfalse) covers file destinations only — Windows cannot swap a directory in place, so an existing directory destination is never replaced. Note that the synchronousfile.movealways replaces a file destination; the async form does not unless asked. - Within one volume a move is a rename and costs nothing regardless of size. Across volumes the host falls back to copy-then-delete-source; the entry still reports
status: 'ok'but carriesreason: 'cross-volume'so the extra cost is visible. deleteAsyncdefaults tomoveToTrash: true, which hands each path to the shell and therefore needs the main thread — those deletes run there in batches of 16 and yield in between.moveToTrash: falsedeletes on a worker thread and removes non-empty directories, which the synchronousfile.deleterefuses to do in that mode.file.cancelOptakes effect part-way through a batch rather than at the end of it: a copy or move stops within one file, aborting the file in flight and removing its partial copy, and a delete stops at the next entry. Entries already done keep their results, every remaining entry is reported asskipped/cancelled, and the run still ends with afile:opCompletecarryingcancelled: true. Closing a popup cancels the operations that popup started; a panel host has no such hook, so its operations run to the end unless cancelled explicitly.cancelled: falsecomes back when the operation had already finished or never existed — the two cases are deliberately indistinguishable.- Both events go to the window that made the call while that window is alive, which is what makes it safe for
resultsto carry real filesystem paths. Once the window is gone the host can no longer resolve it and falls back to the main instance, so a late event may surface in a window that did not start the operation. Two paths skipfile:opComplete— the host shutting down mid-run, and an unexpected host-side failure — so a listener that must not leak state should carry its own timeout rather than wait on it forever.
Metadata probing
- New:
metadata.probeBatchAsyncandmetadata.cancelProbe. Reads happen on a host worker thread, so a few hundred paths no longer stall the UI the waymetadata.readBatchdoes. The call returns a{ operationId, totalCount }receipt; results arrive onmetadata:probeProgressand are followed by exactly onemetadata:probeComplete. - Each result reports where its info came from —
infoSource: 'cached' | 'direct'— which is what makes the endpoint useful for files the media library has never seen. On failure it reports which of'not-found'/'unsupported-format'/'read-error'applies, a distinctionreadBatchcollapses into one generic error string.includeTags(defaulttrue) attaches the flat tag map, with upstream keys upper-cased as inreadBatch; passfalsewhen only technical info is wanted. - Paths may carry a
|subsong:Nsuffix, are resolved independently and are echoed back verbatim so they work as lookup keys. Unlikemetadata.read, the batch surface does not honour the legacy#Nsubsong spelling, which would mis-split an extensionless filename that happens to end in#<digits>. - Path validation is all-or-nothing: if any path fails the host's media read check the whole call is rejected with
PERMISSION_DENIEDand nooperationIdis produced. Per-path rejection is not available. metadata:probeProgressbatches on the same 64-or-100 ms rule as the file events.metadata:probeCompletealways arrives, on the cancelled and failed paths too. Cancellation interrupts the in-progress disk read rather than waiting for it; paths not yet reached are never reported and the interrupted path is reported as neither success nor failure, sosuccessCount + failureCountfalls short oftotalon a cancelled run.
Drag and drop
- A dropped shortcut now tells you what it points at. Windows puts the
.lnkfile itself in a dropped file list, which foobar2000 cannot play, so every path source is now joined by a parallel array of shortcut targets:resolvedPathson thednd:enter/dnd:droppayloads and ondnd.getPathsAsync(), plus the newdnd.getResolvedPaths()for the synchronous snapshot. The two arrays are always the same length, sopaths.map((p, i) => targets[i] ?? p)gives a playable list. - An entry is
nullwhenever no target is available: the path is not a shortcut, the shortcut names a shell namespace object such as the recycle bin rather than a file, the recorded target is too long to come back intact (Windows caps it atMAX_PATH, and a truncated path would name a different file), COM was unavailable, or resolution was skipped to keep the drop responsive. Never an empty string, so a truthiness test is enough. - A target says where the shortcut points, not that the file is there. A broken shortcut reports the path its
.lnkrecorded rather thannull, because Windows hands that path back whether or not the target still exists and the host cannot afford a filesystem check on the thread the drag blocks. Expect a non-null entry to occasionally name nothing. Only.lnkis resolved —.url,.library-msand virtual search results reportnull. - Reading
resolvedPathscosts no filesystem access: the targets were resolved once when the drag arrived, sogetPathsAsync()reads host memory only. - Documented limitation — the page-side snapshot is published to the top-level document only, so
dnd.getPaths()anddnd.getResolvedPaths()answer with an empty array inside an<iframe>and the slot staysnullfor the life of that document. A framed page that needs paths has to receive them from the main frame overpostMessage. - Dragging tracks out of the window is still unsupported and
dnd.startDragkeeps resolving{ success: false, code: 'NOT_SUPPORTED' }. Measurements settled the design question — it needs a dedicated STA thread, because performing the drag on the host's main thread freezes the target application for as long as the gesture lasts — but the work is deliberately not in this release.
Media library queries
- Multi-value tags are no longer truncated to their first value. Since the first release, a track tagged with several artists reported only the first one everywhere outside
metadata.read. Track objects now join the values with,in tag order, without de-duplication — matching what foobar2000 itself displays — forartist,albumArtist,genreandcomposer, each API keeping its own field set. - New:
artists, the atomic form ofartist. A joined string cannot tell one artist named"A, B"from two artistsAandB, so track objects from the library namespace —library.getAll,library.query,library.search,library.getByPathand the otherlibrary.*track endpoints — also carryartists: string[]with the untouched values;artists.join(', ') === artistholds byte for byte. Track objects from other namespaces (playlist.getTracks,playback.getCurrentTrack,queue.get, artwork and event payloads) do not carry the field. - Aggregation counts every credited artist.
library.getArtistsgives each participating artist its own entry, which makestrackCounta participation count: the entries add up to more than the number of tracks, andalbumCount/totalDurationare counted per artist the same way.library.getStats'stotalArtistsnow matches the entry count ofgetArtists, andlibrary.getGenrestreats each value of a multi-valuegenreas its own entry. - New: field projection on
library.searchandlibrary.query.library.searchacceptsoptions.fieldsandlibrary.querygained a fourthfieldsargument. Every returned row then holds exactly the requested keys and nothing else, soTrackInfois a partial view at runtime while the declared type stays complete. Accepted names are the 20 track keys —index,title,artist,artists,album,albumArtist,genre,date,trackNumber,discNumber,duration,path,absolutePath,fileSize,bitrate,sampleRate,channels,codec,subsong,rating— matched case-sensitively. Omit the argument to get all 20. A non-array, an empty array, a non-string element or an unknown name resolves (never rejects) with{ success: false, code: 'INVALID_PARAMS' }and echoes the offending names underdetails.unknownFields. library.searchandlibrary.querynow serialize off the main thread and write their result straight to the wire instead of building an intermediate object graph. The response pipeline stops deep-copying along the way, and a query with no hits short-circuits. Aside from theitemsremoval listed under breaking changes, the JavaScript contract is unchanged: both still resolve the same shape from the same promise.- The Spider Monkey Panel compatibility layer stops re-scanning the library once per page.
fb.GetQueryItemsused to page through hits 500 at a time, and becauselibrary.searchre-scans the whole library on every request, a multi-thousand-hit query paid one full scan per page — 160 scans on a large library. It now issues a one-row probe for the hit count followed by a single request for every hit, narrowed to the five keysFbMetadbHandleactually reads. No observable handle property changes. Trade-off: the probed count is stale if the library changes between the two calls, so hits added in between are dropped; the returned set is not guaranteed stable while the library is being modified concurrently. - Practical note on very large libraries. These changes remove copies and payload, not the cost of producing the rows. A full-field whole-library query still occupies the host's main thread for a substantial time on a six-figure library, dominated by the size of the response being handed to the page. Narrow the projection with
fieldswhen you do not need all 20 keys — it is the single most effective lever available today. On 32-bit hosts also keep the peak in mind: a full-field whole-library result is held in several forms at once while it is parsed, so a six-figure library can reach hundreds of megabytes momentarily.
Title formatting
titleformat.eval,evalBatch,evalFieldsandevalFieldsBatchnow reportinfoAvailable. The host knew whether a track's metadb info was ready and was throwing that signal away, so tag-derived output could come back silently wrong.infoAvailable: falsemeans tag-derived values are untrustworthy. Batch variants carry the flag per row, and rows that failed omit it.- Two limits worth knowing: one flag covers the whole merged script in the
evalFieldsforms, so it cannot tell you which individual field was affected, and it never covers foo_playcount virtual fields. Afieldskey literally namedinfoAvailableoverwrites the flag, matching the existing behaviour ofpathandsuccess.
Errors and permissions
fb2k.invokeinside a subframe now fails immediately. It rejects with anErrorcarryingcode: 'NOT_SUPPORTED'and the messagefb2k.invoke is unavailable in subframes, instead of hanging silently until the 30-second timeout. A detached call (const { invoke } = fb2k) still surfaces itsTypeErroras a rejection rather than a synchronous throw.- A cross-volume directory move now returns
NOT_SUPPORTEDwithdetails.reason: 'cross-volume'rather than a generic failure. A cross-volume file move already succeeded, because the underlying rename silently degrades to a copy. - Path allowlist and blacklist entries are now normalized to the same canonical real-path form the check side uses. An entry written in a different but equivalent spelling — a mapped drive, a junction, a short 8.3 name — previously failed to match the path it was meant to cover.
- Dropping a shortcut, probing metadata and the async file operations are all covered by the permission matrix in the reference; the counts there moved from 67 specs over 64 APIs to 73 over 68.
FileWrite now accepts media-library watch folders
The FileWrite chain — the channel behind every file.* write — gained a media-library watch-folder step, so a path inside a watch folder is now writable even on the system drive, where previously only the non-system-drive step would have allowed it. FileWrite was already the widest write channel exposed to a theme; this widens it further. If you audit themes, this is the channel to look at first, and the watch-folder list is now part of its attack surface.
Window and menus
- Collapsing a menu no longer leaves an invisible click trap. The WebView rendering area is now shrunk in the same step as the menu closing, so no residual transparent region is left behind intercepting clicks meant for the page underneath.
SDK
- New exported types for the async file surface:
FileOpEntry,FileOpAsyncOptions,FileDeleteAsyncOptions, plus payload types for the four new events (FileOpProgressPayload,FileOpCompletePayload,MetadataProbeProgressPayload,MetadataProbeCompletePayload) and their supporting unions (FileOpKind,FileOpStatus,FileOpResultReason,MetadataProbeFailure,MetadataProbeInfoSource). dialog.openFile,saveFileandopenFoldernow document their resolve shapes ({ canceled, filePaths }/{ canceled, filePath }/{ canceled, folderPath }), where the path field is empty on cancellation.dnd.getPathsAsyncis typed against the shared session-paths shape soresolvedPathsis visible to TypeScript.dnd.getResolvedPaths()pads frompaths, so a host that predates the field yields nulls of the right length rather than a short array that would silently misalign an index-paired loop.
Documentation
- Every parameter table in the bilingual API reference now carries a Default column, and filler prose that restated the parameter name has been removed throughout.
- Required-column entries were verified one by one against the C++ handlers' null checks, correcting roughly 50 mismarked parameters across both languages.
library.coverMaxSizeis documented in KB, not bytes. Three brokenlibraryreturn-field tables were rebuilt against the actual host structures. Twelve findings from an adversarial documentation audit were applied, including two reversed semantics and several misleading descriptions.
v1.12.0 (2026-08-13)
Breaking changes in this release
Four changes may require code edits:
- The
dnddrop-zone registry is gone.dnd.registerDropZone/unregisterDropZone/getDropZonesno longer exist; the host now observes drags natively and emitsdnd:enter/dnd:leave/dnd:dropto the window under the cursor, no registration required. Read real paths withfb.dnd.getPathsAsync()or from thednd:droppayload. dnd.startDragno longer fakes success. Dragging tracks out of the window needs a nativeIDropSourcethe component does not provide; the call now resolves with{ success: false, code: 'NOT_SUPPORTED' }instead of reportingsuccess: true.- Window size constraints target the calling window. The six
window.setMinSize/getMinSize/setMaxSize/getMaxSize/setResizable/isResizableendpoints no longer fall back to the main window, and a call that resolves no target now fails. A popup that relied on the old fallback was constraining the main window. DiscoveryContextMenuCommandis no longer a type alias. Readingpath/isDynamic/subGuidoff a context-menu command no longer type-checks — those fields were never populated.
The release stays on a minor version because the project's version axis has carried breaking changes in minor releases before (see 1.6.0). Pin an exact version if you need to upgrade deliberately.
Drag and drop
- Drag and drop is now a native pipeline. The host observes drag gestures itself through a native
IDropTargetbridge and hands the page what HTML5 deliberately hides: real filesystem paths. Standard HTML5 drag events keep firing as before;fb.dndruns alongside them as a side channel. - New surface:
dnd.getPathsAsync(sessionId?)(the reliable read inside adrophandler),dnd.getPaths()/dnd.hasFiles()(synchronous snapshot reads for optimistic UI), anddnd.getCapabilities()(whether this window can deliver paths at all). - Events
dnd:enter/dnd:leave/dnd:drop(payload:sessionId,paths,x,y,keyState) anddnd:capabilitiesChanged, correlated bysessionIdand delivered point-to-point to the window under the cursor. - Paths are withheld from untrusted origins while
hasFilesstays accurate; a DUI / CUI panel (hosting: 'standard') cannot receive paths — branch ongetCapabilities()rather than assuming from the window type. - Migration: delete
registerDropZone/unregisterDropZone/getDropZonescalls and anyzoneIdbookkeeping; keep (or add) plain HTML5dragover/droplisteners for visuals and hit-testing; read real paths withawait fb.dnd.getPathsAsync()inside thedrophandler; gate path-dependent UI ondnd.getCapabilities().
Main menu
menu.runMainMenuCommandno longer leaks host exceptions. On localized foobar2000 builds a host exception previously escaped to JavaScript as a raw host-languageErrorand made the name and path forms fail outright. Failures are now reported assuccess: falsewith acode:MENU_ITEM_DISABLED,MENU_MATCH_AMBIGUOUS(withcandidates), orMENU_COMMAND_NOT_FOUND.menu.getMainMenuleaves are addressable on localized hosts. Leaves are now matched against the same text the host rendered the menu from and backfilled withguid/subGuid; measured 0 of 158 leaves before, 131 of 167 after on a localized host.flags,enabled,checked, andhiddenare now reported as well, where previously none of them were.- Behavior change — a disabled command is refused instead of reporting success. Execution previously returned
success: truefor a greyed-out command, and the same command was refused by name yet "succeeded" by GUID. All three request forms now validate alike and returnMENU_ITEM_DISABLED. A GUID absent from the enumeration is still attempted, because a caller may hold a valid address the enumeration did not surface. - Name and path resolution matches by exact segment. An ambiguous name is reported rather than resolved: on a localized host three separate commands can share one label, so picking the first match would silently run the wrong command. Address by
guidto be unambiguous — it is the only form stable across hosts, since a localized build reports localized labels. menu.runMainMenuCommandacceptssubGuidto address a dynamic child command, paired with its owning command GUID.- A leaf that could not be resolved to an address now says so, carrying
executable: falseandunaddressableReasonrather than appearing as an ordinary command the caller cannot act on.availableon flat enumeration results is read from live host state instead of being alwaystrue, so a disabled command is no longer indistinguishable from an enabled one.
Self-drawn menu
- Per-call presentation options.
menu.show/menu.popuptake a thirdMenuPopupOptionsargument, and keys the caller omits are not sent, so the host keeps its own defaults:windowModel('fullscreen'default, or'contentSized'— draws the root and its first-level submenu as separate compact windows measured to their content, so each panel carries the real DWM backdrop material and the system window shadow; the recommended model for a context menu),css(at most 256 KiB) /cssReplacefor style takeover,backdrop('acrylic'default,'mica','mica-alt','none') withbackdropDarkMode(defaulttrue), andcloseAnimationMs(default0, clamped to0..1000) for an exit fade. - Rich items.
MenuPopupItem.typegains'nowplaying','rating','slider', and'segmented'along with the fields they use (value,min/max/orientation,segments, andcover/title/subtitle), plusiconSvgfor an inline monochrome icon on any row. Icons go through the runtime's allowlist sanitizer; an illegal or oversized one is dropped without failing the row. - New event
menu:valueChangedreports a rating, slider, or segmented change as{ menuId, itemId, value }and keeps the menu open, while ordinary rows still report throughmenu:selectand close it. Becausemenu.popupresolves only on selection or dismissal, subscribe to this event separately when a menu contains value controls. - Fixed — focus-loss dismissal is now reliable, and pooled submenu windows no longer show blank content in the
contentSizedmodel.
Discovery menus
- Behavior change —
discovery.searchCommandsnow searches the context menu as well as the main menu, so result counts increase andtypecarries a new'contextmenu'value. Pass{ scope: 'mainmenu' }for the previous coverage. The endpoint previously reportedtype: 'mainmenu'on every hit while only ever looking at the main menu, which made right-click commands unfindable. - Behavior change —
discovery.searchCommandsfilters entries the host would not show, matching the enumeration endpoints. Pass{ includeHidden: true }for the unfiltered superset. - Behavior change —
discovery.getAllServicescounts context-menu commands inservices.contextMenuCommandsand includes them intotalServices, sototalServiceschanges value.contextMenuHiddenFilteredreports how many entries the filtering removed, andstateKnownis false when nothing was selected or playing. - Search hits carry the same state fields as the enumeration endpoints (
enabled,checked,radioChecked,hidden,stateKnown,flags,source,executable,unaddressableReason), so a caller can tell whether a hit is invocable without a second round trip. When the context family was searched without a track selected or playing, the response'sstateKnownis false and those hits'enabled/checkedmust not be filtered on. - Search case folding is now ASCII-only and can no longer corrupt a UTF-8 sequence. Observable matching behavior for CJK labels is unchanged — they have no case to fold.
discovery.getContextMenuTreeno longer truncates silently. Children were previously capped at 50 and depth at 10 while still reporting the host's realchildCount, sochildren.lengthdisagreed withchildCountwith nothing explaining the difference. The limits are now depth 16 and 512 children per node, and any clipping is reported: apopupnode gives bothchildCountandchildrenReturned, and a node whose subtree was clipped carriestruncatedwithdepthExceeded/childrenExceedednaming the cause. The flags propagate upward, so the response's top-leveltruncatedcovers the whole tree;maxDepthandmaxChildrenPerNodeecho the applied limits.discovery.getContextMenuTreenodes now report state —enabled,checked,radioChecked,hidden,stateKnown,flags— anddepth. Separators carry only their kind, which is all that is meaningful for them.discovery.executeContextMenuCommandreturnshiddenandresolvedon the success path, not only when refusing, so a caller can tell "was not refused" apart from "this build does not report the field".resolvedis false when no registered item owns the GUID, in which case there was no state to evaluate.discovery.getMainMenuCommandsentries expanded from a dynamic submenu now carrystateKnown,executable, andunaddressableReason, matching the static slots.
SDK
- Breaking type fix —
DiscoveryContextMenuCommandwas a type alias forDiscoveryMainMenuCommandand therefore claimed fields the context tier never returns. It is now an independent interface: context items are registered flat and placed by the host, so they have no menupathand no dynamic-expansion fields. TypeScript code that readpath/isDynamic/subGuidoff a context-menu command was reading a field that was never populated. - Added the exported
MenuNodeState,MenuNodeSource, andMenuUnaddressableReasontypes, shared by every menu enumeration result. fb.discovery.searchCommands()accepts{ scope, includeHidden };fb.discovery.getContextMenuCommands()accepts{ includeHidden }.- Fixed SMP main-menu dispatch. A menu id was previously resolved by
pathbeforeguid, and path matching fails outright on localized hosts, soExecuteByIDon a main menu did not work there at all.guidis now preferred (the host resolves it directly);commandIdstill wins for context-menu sessions. - Fixed SMP menu state decoding.
enabled/checkedwere previously re-derived from the rawflagsword, ignoring the normalized booleans the host sends. Those booleans are now preferred, withflagsused only when they are absent. An item markedstateKnown: falseis offered as enabled rather than greyed out, because "state unknown" and "enabled, unchecked" are indistinguishable inflags, and treating unobserved state as disabled hid commands the host would have run.
Tray and menus
- Fixed — the tray menu's "show main window" path no longer depends on the main page. Once the window was minimized or hidden to the tray, the page was deep-suspended, so no
tray:menuItemClickedhandler could run and nothing could callwindow.focusto bring the window back. A left click on the icon was equally silent. Native items (_sys_exit,playbackAction) kept working throughout. - Behavior change —
showSystemItems(defaulttrue) now injects_sys_show("Show Main Window") before_sys_exitin the bottom zone. It restores and foregrounds the main window natively, preserving its maximized / normal placement, and like every native item it does not firetray:menuItemClicked. Menus built withshowSystemItems: truetherefore gain one row; passshowSystemItems: falseto opt out. _sys_showjoins_sys_exitin the exact, case-sensitive reserved-id allowlist, so a frontend that renders its own "show main window" row gets the same native route with itslabel/iconpreserved, and the injection is skipped for it. Lookalikes such as_sys_show_altor_SYS_SHOWremain ordinary user items and do not suppress the injection.playbackActionstill rejects'show-main-window'and'exit': system routes stay exclusive to the reserved ids.
Window
- Behavior change — the six size-constraint endpoints (
window.setMinSize/getMinSize/setMaxSize/getMaxSize/setResizable/isResizable) now target the calling window, or an explicitwindowId, and no longer fall back to the main window. A popup that set its own minimum size previously constrained the main window instead. When no target resolves the call fails rather than reporting another window's constraint. DUI / CUI panel callers are refused withpanelMode: true. - Documentation fix — sizes on these endpoints are physical pixels, not DIP as previously documented. A caller that scaled by the device pixel ratio was applying the factor twice on a high-DPI display. A value round-tripped through a setter and getter is accurate to ±1px.
Interface language
- The plugin's native UI now follows foobar2000's presentation language instead of the Windows UI language, so a localized foobar2000 no longer shows an English preferences page. The language is detected from the host's own strings and falls back to the Windows UI language when detection is not possible.
- Added the Interface Language preference: Auto (follow foobar2000) (default), English, or 中文. Newly opened dialogs apply the change immediately; menu titles and panel descriptions are registered with the host once and need a foobar2000 restart to refresh.
Playlists
- Playlist files from third-party components now expand correctly. Adding a playlist URL or file through the API used a hardcoded list of eight wrapper extensions (
.pls/.m3u/.m3u8/.asx/.wpl/.xspf/.fpl/.cue); a playlist format registered by another installed component was treated as a single track. The host's playlist-loader registry is now consulted at runtime, so any format the host can load is expanded.
Performance and stability
- Fixed — the window no longer stays blank after the WebView2 browser process dies. Only the render process had a recovery path, so an external cause such as a third-party hook injected into the browser process left the window blank with no way to recover; one observed session ran that way for ten hours. The window is now rebuilt, capped at 3 rebuilds within a 10-minute window so a reproducible crash cannot degenerate into a rebuild→crash→rebuild loop. The window expires and the count resets, so an unrelated later failure is still recovered. This is separate from the v1.11.0 render-process handling, which reloads the page and rebuilds the WebView.
v1.11.0 (2026-07-27)
Tray and menus
- Added
TrayMenuItem.playbackAction('play-pause' | 'previous' | 'next' | 'stop') so a custom tray item can declare a playback action the plugin runs natively. Appearance stays caller-controlled; declared items do not emittray:menuItemClicked(same pattern as Electronrole/ TauriPredefinedMenuItem). Valid only on atype:'normal'leaf; unknown tokens or declarations on separator / submenu / rich controls reject the wholesetContextMenu/appendMenuItemscall withINVALID_PARAMS.'exit'is not accepted. Tray-only — no effect onmenu.show.getMenuItemsround-trips the field. Prefer this (or built-inshowPlaybackControls) for background-reliable tray playback while the main page is hidden; plain click→playback.*handlers are not guaranteed then. Available from v1.11.0; probeconfig.getVersionInfo().plugin.versionif you must support older hosts. - Clarified that
tray:menuItemClickedcovers ordinary user items and rich value controls only. Built-in playback / system injections and items declaringplaybackActionexecute natively without the click event; reflect button state fromplayback:*. menu.getMainMenuacceptslocale,i18n, andwithAvailability.locale(default'auto') selects thedisplayLabeltranslation locale and keeps the host's native labels untranslated by default;i18n: falsedisables label translation entirely;withAvailability(defaulttrue) includes per-submenu command availability counters. The SDK signature is nowgetMainMenu(root?, opts?).- Fixed UTF-8 serialization and context-mode selection for custom menus.
DSP and output
dsp.*andoutput.*now actually work. The eleven handlers (dsp.getChain/getPresets/getAvailable/addDsp/removeDsp/moveDsp/applyPreset/setChain,output.getDevices/getEntries/getSettings) were documented but their source files had never been added to the build, so every call failed as an unregistered method. They are compiled and registered from this release on. The publishedfb.dsp.*andfb.output.*SDK wrappers were already shipping and start working against this plugin version; older plugins reject them regardless of SDK version.- Fixed a crash in
output.getDevices. Some output backends report a device name with a "length unknown" sentinel instead of a real length; the handler used that value verbatim and read far past the end of the string, terminating foobar2000. - Fixed
dsp.moveDspmoving items to the wrong slot. Upward moves landed one position short, so moving an item up by one did nothing and no item could reach the end of the chain. Downward moves were already correct. The returnedtonow reports the real final index. - Behavior change —
dsp.setChainrejects a call that contains any unusable entry instead of silently skipping it. Previously a chain built from three entries could apply only two and still reportsuccess: true. Each per-entry failure now returns an index-tagged reason:dsps[0] must be an object(also for a non-object element such as a bare string or number),dsps[0]: guid is required(missing, empty, or not a string),dsps[0]: Invalid GUID format: …, ordsps[0]: DSP not found or no default preset: …(a well-formed GUID for a DSP that is not installed). A missing or non-arraydspsstill fails withdsps array is required. The chain is left untouched whenever a call is rejected. dsp.getPresetsreportsselectedIndex: -1when no preset is selected. It previously returned the internal sentinel18446744073709551615, which is not representable as a JavaScript number and arrived as an unusable float.dsp.getChainalways includesactivePresetandactivePresetIndex, usingnull/-1when no preset is selected or the host does not support presets. The keys were previously absent in those cases, so callers had to probe for them.
Discovery
- Behavior change —
discovery.getMainMenuCommandsanddiscovery.searchCommandsnow expand components that build their submenu at runtime (mainmenu_commands_v2, e.g. ESLyric), so results include child commands in addition to the parent slot. Pass{ expandDynamic: false }for the previous static-registry-only result. - New entry fields:
path,isDynamic,isDynamicParent,subGuid, andflags.getMainMenuCommandsechoesexpandDynamicanddynamicCount, anddiscovery.getAllServicesaddsmainMenuDynamicCommands. An entry flaggedisDynamicParentis a container slot and is not executable on its own. discovery.executeMainMenuCommandaccepts an optionalsubGuidfor commands expanded from a dynamic submenu; without it only the static command GUID is dispatched. The response echoessubGuidanddynamic.
Performance and stability
- Behavior change — while the page is hidden (minimized, covered, tray-hidden, or locked), high-rate regenerable streams stop at the source:
audio:spectrum,playback:time, andplayback:timeHighResare not produced, and resume on the next tick once the page is visible again.window:hoverStateChangedandcursor:hiddenChangedare naturally silent while hidden. Every other event is delivered reliably and in order — nothing is dropped or merged — including async replies such ashttp:response,library:getAllResult, andaudio:fullWaveformReady, and one-off facts such asplayback:itemPlayed. Themes that draw a spectrum or a seek position should read a gap as "page hidden", not as "playback stopped". - Added deep suspend while minimized, covered, locked, or tray-hidden: renderer timers and animations are frozen so the OS can reclaim memory. Controlled by the new advanced-preferences option Deep-suspend WebView when hidden (TrySuspend; frees renderer memory) (default on; turning it off falls back to the previous low-memory path).
- Added the advanced-preferences option Keep WebView active in background while CDP remote debugging is on (tray/minimize/lock) (default on), so screenshot and timing automation over the DevTools Protocol stays stable instead of being suspended.
- Hardened recovery after a WebView2 crash: a failed render process no longer leaves an unresponsive blank window. The page is reloaded up to a bounded number of attempts and the WebView is rebuilt after repeated failures.
- Improved album-art delivery: fixed cache entries that could serve another track's image, tightened request and parameter validation, and moved image decoding off the interface thread so large covers no longer make the window unresponsive.
artwork.*request and response shapes are unchanged.
Metadata
- Fixed
metadata.read,metadata.readByPath, andmetadata.readBatchignoring the track index inside a container. A|subsong:Nsuffix was neither stripped nor honored, so reading a single track out of a CUE sheet, ISO image, or multi-track file either failed outright or returned the first track's tags. This is why such files could be read in the foobar2000 UI but not through the API.metadata.readRawwas already correct. metadata.readandmetadata.readByPathacceptcueIndexto address a track explicitly, matchingmetadata.readRaw. It takes precedence over a|subsong:Nsuffix in the path. Thefb.metadata.read()/readByPath()wrappers take it as a secondoptsargument, and thefb2k_metadata_read/fb2k_metadata_read_by_pathMCP tools declare it.metadata.readBatchdoes not accept it — address per-track reads there with a|subsong:Nsuffix.
SDK
- Added SDK-only additive binary adapters:
fb.file.readBinary(),fb.file.writeBinary(),fb.file.writeDataUrl(),fb.metadata.embedArtworkBytes(), andfb.metadata.embedArtworkFromDataUrl(), plusFileBinaryWriteOptionsandMetadataArtworkBytesOptions. - These helpers adapt
ArrayBuffer/Uint8Arrayvalues and strict Base64 Data URLs to the existingfile.read,file.write, andmetadata.embedArtworkwire contracts. They add no new Bridge endpoints and do not change rawinvokeor existing facades. Canonical Base64 and Data URL validation occurs in the SDK before invocation; Host validation and behavior are unchanged. - Breaking type fix — the published response types for
ui.isMinimized()andui.isAlwaysOnTop()were wrong and now match the wire contract:isMinimizedresolves with{ minimized }(there is noisMinimizedalias), andisAlwaysOnTopresolves with{ enabled, isAlwaysOnTop }(both carry the same value). Runtime behavior is unchanged; TypeScript code written against the old declarations must be updated. fb.playcount.set()no longer sends thecountkey, which the host never read. No behavior change; the wire payload is simply smaller.fb.http.request()now dispatches through the documentedhttp.getendpoint; a stale internal parameter could previously forward a mismatched method name. The verb helpers (fb.http.post()/put()/delete()/patch()) keep dispatching to their own endpoints, including for binary responses.- Added
windowIdparameter typings forwindow.getBackdropPolicyandwindow.setBackdropPolicy.setBackdropPolicyrequiresbackdropPolicyand does not fall back to the main window when no target resolves. - Added optional trailing
optsarguments to five wrappers whose host handlers already read the corresponding keys:fb.file.delete(path, opts?)(moveToTrash),fb.file.copy(source, destination, opts?)(overwrite), andfb.metadata.write(path, tags, opts?)/removeField(path, field, opts?)/removeTag(path, tags, opts?)(cueIndex).metadata.writeacceptingcueIndexcloses a real gap: v1.11.0 wiredcueIndexinto the metadata read path only, so writing a tag to a single track inside a CUE sheet or image file was not expressible through the SDK. Existing call sites are unaffected — every new argument is optional. fb.metadata.readByPath()now resolves withMetadataReadByPathResponseinstead of a bareJsonObject.- Corrected two published type declarations that did not match the host contract:
dsp.setChaintakes a requireddspsarray of{ guid }objects (it was typeddsps?: string[], wrong in optionality, element type, and shape — the host rejects the call unlessdspsis an array, and readsguidoff each entry), and theplaylist:created/playlist:renamedpayloads keepname: string. TypeScript code written against the olddsp.setChaindeclaration must be updated.
v1.10.0 (2026-07-16)
- Added
TrayMenuItem.orientationfortype:'slider'with'horizontal' | 'vertical'; horizontal is the default. Only the exact valueverticalselects vertical behavior (min at the bottom / max at the top; Up/Right increase, Down/Left decrease, and Home/End select the bounds).nativeignores the field and keeps the tiered submenu; older runtimes ignore the unknown field and remain horizontal. Range normalization swapsmax<min;max==minis constant and emits no value; the initial value is clamped; out-of-range IPC values are rejected.getMenuItemsround-trips the field. Available from v1.10.0; themes that must support older hosts should probeconfig.getVersionInfo().plugin.version. - Changed custom-menu focus to two modes: navigation with roving tabindex and real focus, and rich-control editing. ARIA uses
menuitem,menuitemcheckbox, an internalrole=slider, and a segmentedradiogroup;checked:falseremains checkable. Default entrance and exit transform/transition effects are disabled underprefers-reduced-motion: reducewithout changing the hide protocol orcloseAnimationMs. - Added
TrayMenuConfig.layoutModewith'flat' | 'zones'. The default'flat'preserves direct#menu > .fb-itemchildren. Explicit'zones'creates.fb-zone[data-zone]wrappers for non-empty top / playback / bottom sections.nativeignores the field; older runtimes ignore the unknown field and create no wrapper;menu.showis unaffected. Available from v1.10.0; themes that must support older hosts should probeconfig.getVersionInfo().plugin.version. - Changed protected custom-menu CSS so the visible state no longer forces
#menu { display:block !important }. Themes can make the root menu or zones flex or grid containers, but cannot usedisplay:* !importantto reveal a hidden menu. - Hardened custom-menu SVG icons by replacing raw
innerHTMLinjection with DOMParser plus allowlisted element and attribute cloning. Invalid or individually oversized icons are discarded while the menu continues to render.transformis parsed strictly, rejecting prefixes, inter-function junk, and empty arguments, and nodes must be in the SVG namespace. - Added transactional resource-limit validation to
tray.setContextMenu,tray.appendMenuItems, andmenu.showbefore persistent configuration is written or an overlay opens: item ≤ 512,menu.showdepth ≤ 8, segmented options ≤ 64, CSS ≤ 256 KiB, and aggregate SVG ≤ 256 KiB. A single SVG over 32 KiB is discarded without rejecting the whole menu. Other invalid or oversized input returnsINVALID_PARAMSwithfield/limit/actualindetails; this is an intentional incompatibility for unsafe input. - Hardened tray and custom-menu built-in action routing to use trusted internal provenance instead of a public id prefix. The sole compatibility exception is the exact, case-sensitive
_sys_exitin the tray API, preserving the real exit behavior from 1.9.0. Caller-supplied_pb_playPause/_pb_prev/_pb_next/_pb_stopremain ordinary user items and cannot suppress runtime-injected trusted playback items through same-id deduplication. Opaque tokens distinguish duplicate public IDs, and publicmenu.showdoes not elevate them. Each selection or value change carries an unpredictable one-shot token validated against the current menu index; unknown or expired tokens, disabled items, and out-of-range rich values for rating / slider / segmented are rejected. Internalmenu.__*IPC also verifies that the caller is the overlay window and that select / dismiss / ready / submenuPanel / valueChanged match the current menu id; external callers and stale or forged menuId values are rejected without changing menu state. - Custom tray
ContentSizednow measures the root and every first-level submenu offscreen after fonts are ready, then waits for stable dimensions across two consecutive frames. C++ uses 64-bit-safe slot allocation. The fixed HWND region covers only the currently visible root/submenu panel, so reserved space for unopened panels no longer creates a blank acrylic/mica area and the caller's configured backdrop is not silently disabled. - Clarified that a
segmentedvalue change in a custom tray menu follows the keep-open contract. A segment change emitstray:menuItemClickedwith{ id, value }, wherevalueis the zero-based selected-segment index, and does not close the menu, matchingratingandslider. Thewebviewruntime already kept the menu open; this corrects the shared contract and event documentation that previously listed onlyrating/slider. - Fixed extra separators for completely hidden or empty tray-menu sections. Previously, filtering all items with
visible:falsecould leave a leading or trailing separator. Visibility is now filtered before separator decisions, so neither native nor webview menus render that separator. - Corrected the documentation for
TrayMenuItem.icon: base64 ICO remains reserved and neither backend renders it (nativeis text-only and webview rendersiconSvg). UseiconSvgfor menu-item icons.
v1.9.0 (2026-06-18)
- Added icons for normal and submenu items in custom tray menus (
render: 'webview') throughTrayMenuItem.iconSvg = { viewBox, content }. Inline monochrome SVG follows menu text color throughcurrentColorand uses a fixed 8px left-aligned gap. When any peer has an icon, all normal and submenu items reserve a 16px icon column for text alignment.nativemenus ignore it. - Added
config.autoNowPlayingtotray.setContextMenu. When enabled, empty cover/title/subtitle fields on anowplayingitem fall back to the current track when the context menu opens; caller-provided values take precedence. Thecoverfallback iswebview-only and uses a thumbnail of current artwork. title/subtitle use%title%with filename fallback and%artist%, including dynamic streaming titles. - Extended
TrayMenuItem.coverto accepthttp(s)://URLs in addition to existingdata:values and raw base64, allowing streaming frontends to pass live artwork directly. - Updated the SDK package to
1.9.0.
v1.8.0 (2026-06-10)
- Added custom-menu rendering through
menu.show/menu.close, with WebView-rendered content and recursive submenus. The menu window uses a content-sized fixed-window strategy to prevent expansion flicker. - Added
render: 'webview'totray.*, allowing tray context menus to use custom rendering consistent with the theme. - Added
tray.setMenuItemStateto update one menu item's state without rebuilding the entire menu. - Fixed clicks on always-on-top popups such as desktop lyrics occasionally bringing the main window to the foreground and making it topmost (the rollback path inserted z-order into the topmost band and formed a sink restoration reference loop).
- Fixed missing global
HTMLElementTagNameMapdeclarations in the published SDK so npm consumers regain type completion forfb-*custom elements. - Fixed package-script compatibility with newer PowerShell versions when generating
.fb2k-componentarchives. - Hardened the HttpApi asynchronous-request exception boundary and fixed a NUL string-handling defect in LibraryApi.
- Updated the SDK package to
1.8.0;bump-version.ps1now also synchronizessdk/package-lock.jsonand the VitePress navigation version.
v1.7.0 (2026-06-06)
- Added Taskbar & Tray capabilities.
taskbar.*can configure thumbnail-toolbar buttons, progress, overlay icons, and flash notifications;tray.*can create a system-tray icon, balloon notifications, and context menus. - Added incremental menu management to
tray.*throughappendMenuItems/removeMenuItems/clearMenuItems/getMenuItems, allowingtop/playback/bottomsections to be maintained without rebuilding the entire menu. - Added
taskbar:buttonClicked,tray:click,tray:doubleClick,tray:menuItemClicked, andtray:beforeContextMenuevents for taskbar-thumbnail and tray interaction. - Added
webview:processFailed, which broadcasts diagnostics for WebView2 render-process failures and works with automatic render-process recovery to reduce blank-window failures. - Added high-resolution playback-position event
playback:timeHighRes, driven by a dedicated WinAPI timer for sub-second lyrics and progress updates. - Moved cold-cache full serialization for
library.getAllto a background thread. The SDK waits forlibrary:getAllResultand correlates it byrequestIdso large-library queries do not block the UI. - Fixed the window restoration path after hiding to tray, including WebView surface recovery for
window.focus/ hidden restore, reducing blank surfaces after minimize, tray hide, or Alt+Tab restoration. - Fixed corrupt base64 for the taskbar-thumbnail pause icon and corrected HICON ownership, preventing malformed playback buttons and explorer.exe crashes.
- Updated the SDK package to
1.7.0, including the new Taskbar & Tray types and event declarations. - Added a Taskbar & Tray API page to VitePress and synchronized Cursor, high-frequency Playback events, and related examples.
v1.6.1 (2026-05-20)
- Added the
cursor.*namespace:cursor.setHidden(hidden)/cursor.isHidden()explicitly control client-area cursor visibility, addressing unreliable CSScursor: nonebehavior under Visual Hosting. - Added per-window
cursor:hiddenChangedevents. - Added the
insecureTlsparameter tofb.http.*behind two gates: the globalAllow self-signed / invalid TLS certificatessetting must be ON and the request must specifyinsecureTls: true. This allows explicitly authorized access to self-signed intranet services such as Plex / Jellyfin / Lidarr. - Added
responseType: 'arraybuffer' | 'binary'tofb.http.*; the body is base64-decoded to anArrayBuffer, so binary artwork and fonts no longer fail strict UTF-8 validation. - Updated the VitePress cursor.md / http.md / events.md documentation for these changes.
v1.6.0 (2026-05-11)
- Removed
durationfromplaylist.getAllto avoid reading every track solely to calculate duration;playlist.getActive/playlist.getPlayingstill return it. - Changed
http.get/http.post/http.headto asynchronous by default. Passasync: falseexplicitly for a synchronous call.
v1.1.17 (2026-02-06)
- Added full multi-window support.
- Added
window.createPopup/closePopup/closeAllPopups/getAllWindows. - Added
window.sendMessage/window.broadcastfor inter-window messaging. - Added asynchronous close, frameless windows, and transparent backgrounds.