Skill v1.0.2
currentAutomated scan100/100~2 modified
version: "1.0.2" name: ui-guide description: Coordinate systems, scaling architecture, hit testing, skin sprite rendering, window layout, and Compact Mode for NullPlayer's UI. Use when working on window scaling, skin rendering, coordinate transforms, visual layout, compact/status-item windows, or playlist/marquee text rendering.
UI & Skin Rendering Guide
Reference for working on NullPlayer's skin-style UI and skin system.
Coordinate Systems
Winamp: Y=0 at top, Y increases downward macOS: Y=0 at bottom, Y increases upward
Apply this transform before drawing:
context.translateBy(x: 0, y: bounds.height)context.scaleBy(x: 1, y: -1)
Text requires counter-flip (NSString draws upside-down after the transform):
context.saveGState()let centerY = textY + fontSize / 2context.translateBy(x: 0, y: centerY)context.scaleBy(x: 1, y: -1)context.translateBy(x: 0, y: -centerY)text.draw(at: NSPoint(x: textX, y: textY), withAttributes: attrs)context.restoreGState()
Scaling Architecture
NullPlayer uses two different resize modes for windows:
Scaling Mode (Main Window, EQ)
Windows scale via context transform. Draw at original size, everything gets bigger/smaller proportionally:
var scaleFactor: CGFloat {bounds.width / originalWindowSize.width}override func draw(_ dirtyRect: NSRect) {let scale = scaleFactorcontext.translateBy(x: 0, y: bounds.height)context.scaleBy(x: 1, y: -1)// Use low interpolation for clean sprite scaling on large monitors// .none causes artifacts, .high causes blur// NOTE: For non-Retina specific fixes, see non-retina-fixes skillcontext.interpolationQuality = .lowif scale != 1.0 {let scaledWidth = originalWindowSize.width * scalelet scaledHeight = originalWindowSize.height * scalelet offsetX = (bounds.width - scaledWidth) / 2let offsetY = (bounds.height - scaledHeight) / 2context.translateBy(x: offsetX, y: offsetY)context.scaleBy(x: scale, y: scale)}let drawBounds = NSRect(origin: .zero, size: originalWindowSize)// Draw using drawBounds, NOT bounds}
Stretch Expansion Mode (Playlist, Spectrum, Waveform)
Center-stack secondary windows now support horizontal and vertical stretching:
- Playlist, Spectrum, and Waveform use skin minimum sizes and
maxSize = .greatestFiniteMagnitude - Default open width still aligns to main window width
- Reopen without a saved frame resets to default docked frame below main
- Restored classic frames preserve width for windows that support stretch (playlist + waveform)
For classic playlist rendering, UI scale is derived from the main-window UI Size level, not the stretched playlist width. This keeps bitmap text and chrome stable while allowing wider windows:
private var scaleFactor: CGFloat {if let mainWidth = WindowManager.shared.mainWindowController?.window?.frame.width,mainWidth > 0 {return mainWidth / Skin.baseMainSize.width}return Skin.scaleFactor * WindowManager.shared.classicScaleMultiplier}
Anti-pattern (regression source):
- Letting classic playlist width stretch freely while deriving scale from a different source can create fractional skin-space widths.
- With
PLEDITtiled title bars, fractional widths produce visible section seams/line artifacts in the top decorative bar.
Safe pattern:
- Derive classic playlist render scale from current main-window width.
- Snap classic playlist width in skin space to
width = (N * 25) + 50before applying frame updates.
effectiveWindowSize expands in both dimensions in skin space:
private var effectiveWindowSize: NSSize {let scale = scaleFactorlet effectiveWidth = bounds.width / scalelet effectiveHeight = bounds.height / scalereturn NSSize(width: effectiveWidth, height: max(originalWindowSize.height, effectiveHeight))}
Hit Testing (Scaling Mode)
Convert view coordinates to skin coordinates:
private func convertToWinampCoordinates(_ point: NSPoint) -> NSPoint {let scale = scaleFactorlet scaledWidth = originalWindowSize.width * scalelet scaledHeight = originalWindowSize.height * scalelet offsetX = (bounds.width - scaledWidth) / 2let offsetY = (bounds.height - scaledHeight) / 2let unscaledX = (point.x - offsetX) / scalelet unscaledY = (point.y - offsetY) / scalelet winampY = originalWindowSize.height - unscaledYreturn NSPoint(x: unscaledX, y: winampY)}
Skin File Structure
.wsz files are ZIP archives containing:
| File | Purpose | |
|---|---|---|
| MAIN.BMP | Main window background | |
| CBUTTONS.BMP | Transport buttons | |
| TITLEBAR.BMP | Title bar sprites | |
| SHUFREP.BMP | Shuffle/repeat/EQ/playlist toggles | |
| POSBAR.BMP | Position slider | |
| VOLUME.BMP | Volume slider | |
| NUMBERS.BMP | Time display digits | |
| TEXT.BMP | Marquee font | |
| EQMAIN.BMP | Equalizer (275x315) | |
| PLEDIT.BMP | Playlist sprites | |
| PLEDIT.TXT | Playlist colors |
BMP Parsing
Classic skin BMPs may be 1-bit, 4-bit, 8-bit, 24-bit, or 32-bit. Row stride must be computed in bits and then aligned to 4 bytes:
let rowSize = ((width * bitsPerPixel + 31) / 32) * 4
Do not approximate packed formats as max(1, bitsPerPixel / 8) bytes per pixel. That treats 4-bit skins as one byte per pixel and misaligns every row after the first, which scrambles 16-color skin sprites such as CBUTTONS.BMP, NUMBERS.BMP, and PLEDIT.BMP.
Sprite Drawing
Sprites are defined in SkinElements.swift and drawn via SkinRenderer:
private func drawSprite(from image: NSImage, sourceRect: NSRect, to destRect: NSRect, in context: CGContext) {guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }let flippedY = image.size.height - sourceRect.origin.y - sourceRect.heightlet sourceInCG = CGRect(x: sourceRect.origin.x, y: flippedY, width: sourceRect.width, height: sourceRect.height)if let cropped = cgImage.cropping(to: sourceInCG) {context.draw(cropped, in: destRect)}}
Classic Time Display
Classic main-window skins bake the colon into MAIN.BMP, while NUMBERS.BMP supplies 9×13 digit sprites. Normal MM:SS times use the two standard minute cells at x=48 and x=60.
For times of 100 minutes or more, keep the full MMM:SS value and seconds:
- Draw the leading minute digit at x=36, reusing the normal minus-sign slot.
- Keep the remaining minute digits at x=48 and x=60 so all three use native-size skin art.
- In remaining mode, draw the minus at x=24 and shift the playback-status icon from x=26 to x=14.
- Only pathological 4+ digit minute values compress within the expanded three-cell field.
- Clamp every sprite digit index to
0...9before calculating its source rectangle.
Do not convert long classic times to H:MM; users need the seconds, and the unused left-side space makes a native-size third minute digit possible.
Tile-Aligned Widths
Windows using PLEDIT tiles (25px) must have tile-aligned widths to avoid artifacts:
Width = (N * 25) + 50 (50 = left corner + right corner)
Valid widths: 275, 300, 425, 450, 475, 500, 550px
Non-Retina displays: Even with aligned widths, tile seams may be visible on 1x displays. See the non-retina-fixes skill for techniques like background fill, tile overlap, and bottom-to-top drawing.
Classic Playlist-Style Window Chrome
drawPlaylistWindow / drawSpectrumAnalyzerWindow / drawProjectMNormal / drawPlexBrowserWindow all share three helpers in SkinRenderer and render as one continuous U-shape outline:
- `drawPlaylistStyleSideBorders` — vertical
leftSideTile(mirrored on the right). The side borders extend tobounds.height, INCLUDING the bottom-corner regions, so the outer gold trim runs continuously down each side. - `drawPlaylistStyleBottomBorder` — rotated
leftSideTilestrip inset between the side borders (x = 12tobounds.width − 12), plus a 2px-tall gold-trim row tiled across the FULL window width at the very bottom. The gold trim is cropped from the rotated tile's bottom 2 rows, which come from the source's gold-bevel column, so every pixel matches the side borders' outer trim color. - Top-right corner fix — the right corner of the title bar is rendered by MIRRORING the
leftCornersprite (not by drawing the originalrightCorner). The originalrightCornerartwork was designed to abut the legacy 20-wide scrollbar tile, so its inner bevel sits too far inward and leaves the interior content area visibly wider under the title bar than below. The close (and shade, where applicable) button icons baked into the originalrightCornerare re-drawn on top from sprite coords(167, 3, 9, 9)and(158, 3, 9, 9)(with+21y offset for the inactive state).
Bottom-border thickness lives in layout structs: Playlist.bottomHeight, SpectrumWindow.Layout.bottomBorder, WaveformWindow.Layout.bottomBorder, ProjectM.Layout.bottomBorder, PlexBrowser.Layout.statusBarHeight, and LibraryWindow.Layout.statusBarHeight are all 7 * Skin.scaleFactor. Interior content rendering uses these constants, so keep them in sync if you change the strip height.
Pixel snapping: Both helpers snap tile destinations with .rounded(.down) to avoid sub-pixel rendering that bleeds the default Winamp skin's blue-tinted edge pixels between adjacent tiles. See non-retina-fixes skill for the underlying issue.
Menu Bar Integration (AppKit)
When adding or refactoring top menu bar content:
- Build dedicated menu-bar trees (
buildMenuBar*) instead of reusing context-menuNSMenuIteminstances. - Avoid
NSMenuItem.copy()for action-bearing items; copied items can lose expected target/action behavior in this app. - Keep side effects (network discovery, long-running work) out of menu construction.
- Prefer lifecycle startup for services and
menuNeedsUpdate(_:)for state refresh when a menu opens. - For Sonos room selection UX, use
SonosRoomCheckboxViewwhen persistent-open submenu behavior is required. - For library-browser column visibility menus, use
ColumnVisibilityCheckboxViewfor persistent-open checkbox rows. Keep column preferences mode-scoped: Modern usesBrowserVisible*Columns; Classic usesClassicBrowserVisible*Columns.
Dockable Center-Stack Windows
Main, EQ, Playlist, Spectrum, Waveform, Audio Analysis, PeppyMeter, and Flow all participate in the center stack managed by WindowManager.
- Width is normalized to the main stack
- Height is window-specific: Flow is single-height; PeppyMeter uses a 1.75x landscape height
- Saved frames are restored through
WindowManagerrather than ad hoc per-window logic - Opening a center-stack window must calculate gaps from windows actually docked below main, not
every visible stack-capable window. Use dockedCenterStackWindowsBelowMain(mainFrame:) (vertical adjacency within dockThreshold plus horizontal overlap) so detached windows moved aside do not make new windows drift downward below the floating stack.
- Modern and classic implementations should expose a provider protocol in
App/soWindowManagercan manage both without mode-specific branching outside window creation - Modern dockable windows must not create a second visual border by adding their own outer content gutter or rounded inner panel around the main content. Use the shared auxiliary chrome inset (
ModernSkinElements.*BorderWidth) as the only window border. If content needs internal breathing room, apply it inside the renderer/content layout, not by shrinking the whole chrome content rect. Flow and PeppyMeter are explicit regression examples: extra content padding made their modern windows look like they had heavy borders, while Metal used the correct thin-edge treatment. - A dockable window that draws its own content rect (rather than hosting a child view that fills the content area) must pass that rect through
NSRect.expandingThroughJoinedEdges(in:borderWidth:adjacentEdges:)before drawing — in every render style (classic, modern, metal). On any edge docked to a neighbor the shared border is suppressed (modern seamless docking, Metal's thin border, classic flush docking); without the content bleed a ~1px background strip shows through as a hairline seam on 1x displays. This was issue #364 (PeppyMeter/Flow): the helper originally short-circuited for non-metal render styles, so only Metal was immune. The helper self-guards onborderWidth > 0 && !adjacentEdges.isEmptyand only expands across small edge-adjacent chrome/border gaps, so it is a no-op on non-docked edges and will not jump body content across a visible title bar. Windows that host a child view filling the content area do not need this. - Animated dockable windows whose content reaches a joined edge need a separate repaint guard. Do not blindly copy PeppyMeter's
setNeedsDisplay(contentAreaRect())content-only redraw if the content is flush with a chrome or docked edge. Invalidate a smaller animation rect that excludes the edge strip, clip drawing to that rect, and still pass the stable full content rect into the renderer. Full paints should draw animated content before chrome/borders so the chrome owns the final edge pixels. Flow's bottom-edge flicker when locked above Waveform was the regression that proved this rule.
For new center-stack windows, follow the waveform/spectrum pattern:
- Shared non-UI logic in a neutral folder (for example
Waveform/) - Classic chrome in
Windows/... - Modern chrome in
Windows/Modern... - Registration and docking behavior in
WindowManager
Window Dragging (MUST)
A center-stack window's mouseDown must end with a content-area fallthrough that starts a window drag for any click that did not hit an interactive region — the close button, sliders, playlist rows, a seek/scrub area, or a body that is itself a click target. Windows whose body is a control are the exceptions and do not whole-face drag: Waveform's body is a scrub area, and ProjectM drags only from its top-quarter zone because the lower body opens the preset-ratings overlay. For every other "plain display" window (Spectrum, Flow, Audio Analysis, PeppyMeter) the whole face drags.
Use SpectrumView.mouseDown as the canonical implementation. Order the checks:
- Close button → set pressed state,
return. - Any
clickCount == 2action (e.g. Flow's direction toggle, Spectrum's quality cycle) →return.
This must come before the fallthrough or it becomes dead code.
- Title bar → start a title-bar drag,
return. - Fallthrough → start a window drag for the remaining face.
Dragging is not just mouseDown. A draggable window also needs, mirroring SpectrumView:
override func acceptsFirstMouse(...) -> Bool { true }isDraggingWindow/windowDragStartPointstate set inmouseDownmouseDraggedthat moves the window origin throughWindowManager.shared.windowWillMove(_:to:)mouseUpthat callsWindowManager.shared.windowDidFinishDragging(_:)and clearsisDraggingWindow
Pass WindowManager.shared.windowWillStartDragging(window, fromTitleBar:) to begin the drag. The fromTitleBar argument is currently inert (the drag logic ignores it); pass whatever documents the click origin — title-bar branches pass true, and the fallthrough conventionally passes the window's Hide-Title-Bars state (hideTitleBars classic / effectiveHideTitleBars(for:) modern). Do not agonize over the value; it does not change behavior today.
Library Window Position Memory
The Library/browser window is not a center-stack window — it does not snap back into the column below the main window. Instead it remembers where the user last put it (issue #326):
WindowManager.lastPlexBrowserFramecaches the frame on every hide/close.
togglePlexBrowser() caches before orderOut; both controllers' windowWillClose call rememberPlexBrowserFrameBeforeClose() for the red-button path.
showPlexBrowser(at:)applies a priority chain: explicit restored frame (launch / mode
rebuild) → remembered session frame → default right-of-stack layout (first-ever open only).
- Always capture the frame via
LibraryBrowserWindowProviding.frameForPositionMemory,
which returns window.frame.
- Do not leak across UI mode switches:
teardownModeDependentWindows()clears
lastPlexBrowserFrame after nil'ing the controller so a classic frame can't apply to the modern window (or vice-versa). An open library that must survive the switch is repositioned explicitly from recreateModeDependentLayout's snapshot frame.
- Persistence (
AppStateManager): saveswm.plexBrowserFrameForPersistence— the live
controller frame even when orderOut-hidden (fixes Compact Mode) or the last remembered frame (closed at quit). On restore, seedPlexBrowserFrame(_:) primes the cache when the library was not reopened, so the first open uses the saved position.
Custom Sprites
For on/off states, stack vertically in NSImage:
- y=0-11: ON state (active)
- y=12-23: OFF state (inactive)
Due to coordinate flipping:
sourceRect y=12selects bottom half (active)sourceRect y=0selects top half (inactive)
let sourceRect = isActive ?NSRect(x: 0, y: 12, width: 27, height: 12) :NSRect(x: 0, y: 0, width: 27, height: 12)
Classic EQ Skin Art
The classic EQ should use EQMAIN.BMP for themed slider and graph art, not hardcoded color bars.
Slider tracks use the 28-state spline sprites in EQMAIN.BMP:
| States | Source region | |
|---|---|---|
| 0-13 | x = 13 + state * 15, y = 164, 15x63 | |
| 14-27 | x = 13 + (state - 14) * 15, y = 229, 15x63 |
Map EQ values with state = round(normalizedValue * 27), where normalizedValue = (value + 12) / 24. State 0 is lowest/cut (green in the default skin), and state 27 is highest/boost (red in the default skin). Draw the track sprite first, then draw the 11x11 thumb (x=0, y=164) on top. Before drawing a skin-art track, validate that the full source rect is present in EQMAIN.BMP. Some placeholder or partial skins omit the extended 315px EQ art region; those must fall back to the programmatic slider track/knob instead of stretching a partial crop or silently drawing nothing.
The graph well is already part of the EQMAIN.BMP background (0,0,275,116). Do not paint a separate black background, grid, or border over it. The classic graph curve samples its color ramp from the 1x19 vertical gradient at EQMAIN.BMP coordinate (115,294), with top = +12 dB and bottom = -12 dB. This keeps non-default skins (for example purple or monochrome EQ themes) visually consistent with their source artwork. If that gradient is absent, keep the built-in fallback color ramp.
Playlist Text Rendering
The playlist window renders all text using the same bitmap font (TEXT.BMP) as the main window, ensuring visual consistency across the application.
Implementation
Located in Windows/Playlist/PlaylistView.swift:
// All playlist text uses bitmap font from TEXT.BMP// CGImage is cached outside draw cycle to prevent cross-window interferenceprivate var cachedTextBitmapCGImage: CGImage?private func cacheTextBitmapCGImage() {guard let skin = WindowManager.shared.currentSkin,let textImage = skin.text else { return }cachedTextBitmapCGImage = textImage.cgImage(forProposedRect: nil, context: nil, hints: nil)}// Characters are drawn using CGContext with proper coordinate flippingprivate func drawBitmapText(_ text: String, at position: NSPoint, in context: CGContext, skin: Skin?, isSelected: Bool = false) {// Crop each character from cached CGImage// Apply Y-flip for CGContext coordinate system// For selected tracks, convert green pixels to white}
Marquee Scrolling
The currently playing track marquees when its title is too long:
// Timer-based marquee offset (8Hz update rate)private var marqueeOffset: CGFloat = 0private var currentTrackTextWidth: CGFloat = 0// In drawTrackText(), current track uses marqueeOffset for scrollinglet xOffset = needsMarquee ? -marqueeOffset : 0
Unicode Fallback
The bitmap font (TEXT.BMP) only supports ASCII characters. Track titles with Japanese, Chinese, Korean, Cyrillic, Arabic, or other non-Latin characters are automatically detected and rendered using system font fallback, matching the behavior of the main window marquee.
private func containsNonLatinCharacters(_ text: String) -> Bool {// Returns true if text contains characters outside A-Z, 0-9, and common symbols}
This ensures:
- Latin text: Uses skin bitmap font for authentic look
- Non-Latin text: Falls back to system font for proper Unicode display
- Mixed text: Falls back to system font if any non-Latin characters present
Selected Track Appearance
Selected tracks display white text instead of green. This uses pixel manipulation:
private func convertToWhite(_ charImage: CGImage, charWidth: Int, charHeight: Int) -> CGImage? {// Convert green (0, G, 0) pixels to white (G, G, G)// Magenta (255, 0, 255) pixels are treated as transparent}
Auto-Selection
When the playlist opens while music is playing, the current track is auto-selected:
override func viewDidMoveToWindow() {// Auto-select the currently playing track when playlist opensif selectedIndices.isEmpty && engine.currentIndex >= 0 {selectedIndices = [engine.currentIndex]}}
Cross-Window Interference Prevention
A key issue was NSImage.cgImage() affecting shared graphics state during render cycles, causing the main window marquee to switch fonts when the playlist scrolled.
Solution: Cache CGImage representation of TEXT.BMP outside of draw cycles, called only when skin changes or view initializes.
Main Window Marquee
The main window uses a scrolling marquee to display the current track title.
Skin Bitmap Font
By default, the marquee uses the skin's TEXT.BMP bitmap font, which provides the authentic Winamp look. This font only supports:
- A-Z (case-insensitive)
- 0-9
- Common symbols:
" @ : ( ) - ' ! _ + \ / [ ] ^ & % . = $ # ? *
Unicode Fallback
When track titles contain characters not supported by the skin font (Japanese, Cyrillic, Chinese, Korean, accented characters, etc.), the marquee automatically falls back to system font rendering:
private func containsNonLatinCharacters(_ text: String) -> Bool {for char in text {switch char {case "A"..."Z", "a"..."z", "0"..."9":continuecase " ", "\"", "@", ":", "(", ")", "-", "'", "!", "_", "+", "\\", "/","[", "]", "^", "&", "%", ".", "=", "$", "#", "?", "*":continuedefault:return true // Non-Latin character detected}}return false}
This ensures:
- Latin text: Uses skin bitmap font for authentic look
- Non-Latin text: Falls back to system font for proper Unicode display
- Mixed text: Falls back to system font if any non-Latin characters present
The system font fallback maintains the green color and scrolling behavior, just with full Unicode support.
White Text Rendering
Some UI elements (like library/server names in the browser) require white text instead of the standard green skin font. This is implemented in SkinRenderer.drawSkinTextWhite().
Implementation
White text is rendered using an offscreen buffer approach to avoid blend mode artifacts:
// For each character:// 1. Crop character from TEXT.BMP// 2. Draw to small offscreen CGContext at 1x scale// 3. Convert pixels: green channel (0, G, 0) → white (G, G, G)// 4. Draw result to main contextfor i in 0..<(charWidth * charHeight) {let offset = i * 4let g = pixels[offset + 1] // Green channel = brightness// Skip transparent and magenta background pixelsif a == 0 || isMagenta { continue }// Convert to white using green channel as brightnesspixels[offset] = g // Rpixels[offset + 1] = g // Gpixels[offset + 2] = g // B}
Why Not Blend Modes?
Previous approaches using CGContext blend modes (.color, .saturation) caused artifacts:
- Green edges visible at scaled text boundaries
- Artifacts appearing/disappearing when switching views
- Sub-pixel rendering issues with scaled contexts
The offscreen buffer approach processes pixels at native resolution before scaling, eliminating these issues.
Common Pitfalls
- Using `bounds` instead of `drawBounds` after scaling transform
- Forgetting text counter-flip - text appears upside-down
- Hit testing in view coords - must convert to skin coords first
- Non-tile-aligned widths - causes sprite interpolation artifacts
- Drawing over skin sprites - they already contain labels
- Using blend modes for color conversion - causes sub-pixel artifacts when scaling; use offscreen pixel manipulation instead
- Tile seams on non-Retina - visible lines at tile boundaries on 1x displays; requires background fill, overlap, and careful draw order (see non-retina-fixes skill)
Key Files
| File | Purpose | |
|---|---|---|
Skin/SkinElements.swift | All sprite coordinates | |
Skin/SkinRenderer.swift | Drawing code | |
Skin/SkinLoader.swift | WSZ loading, BMP parsing | |
Skin/MarqueeLayer.swift | Main window marquee (bitmap font, CALayer-based) | |
Windows/Playlist/PlaylistView.swift | Playlist view with bitmap font rendering | |
Windows/*/View.swift | Window views |
Art Visualizer Window
The Art Visualizer is an audio-reactive album art visualization window that uses Metal shaders to transform album artwork based on music frequencies.
Key Files
| File | Purpose | |
|---|---|---|
Visualization/AudioReactiveUniforms.swift | Audio data struct for shaders | |
Visualization/ShaderManager.swift | Metal pipeline management | |
Visualization/ArtworkVisualizerView.swift | MTKView rendering | |
Windows/ArtVisualizer/ArtVisualizerWindowController.swift | Window controller | |
Windows/ArtVisualizer/ArtVisualizerContainerView.swift | Window chrome |
Effect Presets
| Effect | Description | |
|---|---|---|
| Clean | Original artwork, no effects | |
| Subtle Pulse | Gentle brightness/scale pulse on beats | |
| Liquid Dreams | Flowing displacement with color shifts | |
| Glitch City | Heavy RGB split and block glitches | |
| Cosmic Mirror | Kaleidoscope with chromatic aberration | |
| Deep Bass | Intense displacement on low frequencies |
Keyboard Controls (when focused)
Escape- Close window (or exit fullscreen)Enter- Toggle fullscreenLeft/Right- Cycle through effectsUp/Down- Adjust intensity
Browser Integration
When in ART-only mode in the Library Browser, a "VIS" button appears next to the ART button. Clicking it opens the Art Visualizer window with the currently displayed artwork.
Audio Analysis
The visualizer uses the existing 75-band spectrum data from AudioEngine:
- Bands 0-9: Bass (20-250Hz)
- Bands 10-35: Mid (250-4000Hz)
- Bands 36-74: Treble (4000-20000Hz)
Beat detection triggers on bass energy spikes above threshold.
Spectrum Analyzer Window
A standalone Metal-based spectrum analyzer visualization window that provides a larger, more detailed view of the audio spectrum than the main window's built-in analyzer.
Opening the Window
- Click the spectrum analyzer display in the main window
- Context Menu → Spectrum Analyzer
- Window menu → Spectrum Analyzer
Note: Double-clicking the visualization area cycles the main window vis mode (Spectrum → Fire) instead of opening this window.
Window Docking
The Spectrum Analyzer participates in the docking system alongside Main, EQ, and Playlist:
- Docks and moves with the window group when dragged
- Opens below the current vertical stack (Main → EQ → Playlist → Spectrum)
- State (visibility and position) saved with "Remember State on Quit"
- Center stack collapse: hiding a stack window (EQ, Playlist, Spectrum) slides windows below it up by the closed window's height. Implemented via
slideUpWindowsBelow(closingFrame:)inWindowManager. The closing frame must be captured BEFOREorderOut.
Key Files
| File | Purpose | |
|---|---|---|
Visualization/SpectrumAnalyzerView.swift | Metal-based spectrum view component | |
Visualization/SpectrumShaders.metal | GPU shaders for bar rendering | |
Visualization/FlameShaders.metal | Fire mode shaders (compute + render) | |
Visualization/CosmicShaders.metal | JWST mode shader | |
Visualization/ElectricityShaders.metal | Lightning mode shader | |
Visualization/MatrixShaders.metal | Matrix mode shader | |
Visualization/SnowShaders.metal | Snow mode shader | |
Windows/Spectrum/SpectrumWindowController.swift | Window controller | |
Windows/Spectrum/SpectrumView.swift | Container view with skin chrome |
Quality Modes
| Mode | Description | |
|---|---|---|
| Winamp | Discrete color bands from skin's viscolor.txt with floating peak indicators, 3D bar shading, and segmented LED gaps | |
| Enhanced | Rainbow LED matrix with gravity-bouncing peaks, warm amber fade trails, 3D inner glow cells, and anti-aliased rounded corners | |
| Ultra | Maximum fidelity seamless gradient with smooth exponential decay, perceptual gamma, and warm color trails | |
| Fire | GPU fire simulation with audio-reactive flame tongues in 4 color styles | |
| JWST | Deep space flythrough with 3D star field, vivid JWST diffraction flares as intensity indicators, and rare giant flare events | |
| Lightning | GPU lightning storm with fractal bolts mapped to spectrum peaks and multiple color schemes | |
| Matrix | Falling digital rain with procedural glyphs and selectable color/intensity styles | |
| Snow | Audio-reactive layered snowfall with smooth flurry-to-blizzard intensity shifts |
Decay/Responsiveness Modes
Controls how quickly spectrum bars fall after peaks:
| Mode | Retention | Feel | |
|---|---|---|---|
| Instant | 0% | No smoothing, immediate response | |
| Snappy | 25% | Fast and punchy (default) | |
| Balanced | 40% | Good middle ground | |
| Smooth | 55% | Original Winamp feel |
Window Specifications
- Default size: 275x116 (same as main window at 1x)
- Stretching: Width and height can expand; minimum width stays skin-defined
- Bar count: 84 bars (vs 19 in main window)
- Refresh: 60Hz via CVDisplayLink
- Skin colors: Uses skin's
viscolor.txt(24 colors)
Context Menu
Right-click on the spectrum window for:
- Mode submenu - Switch between Winamp/Enhanced/Ultra/Fire/JWST/Lightning/Matrix/Snow modes
- Responsiveness submenu - Adjust decay behavior
- Normalization submenu - Choose Accurate/Adaptive/Dynamic scaling
- Flame Style - Choose flame color preset (Fire mode only)
- Fire Intensity - Choose Mellow or Intense reactivity (Fire mode only)
- Lightning Style - Choose lightning palette (Lightning mode only)
- Matrix Color - Choose matrix palette (Matrix mode only)
- Matrix Intensity - Choose matrix reactivity profile (Matrix mode only)
- Close - Close the window
Settings are persisted across app restarts.
Hide Title Bars Mode (Modern UI Only)
Two-tier behavior controlled by effectiveHideTitleBars(for:) in WindowManager:
- HT Off (default baseline): EQ/Playlist/Spectrum/Waveform always hide titlebars when docked, regardless of the HT setting. Main, ProjectM, and Library Browser show titlebars.
- HT On: ALL 6 windows hide titlebars unconditionally (docked or not). Main window frame size remains unchanged; the main view internally remaps/scales content to fill the reclaimed titlebar area with no top gap.
Key implementation details:
toggleHideTitleBars()keeps the modern main window at full-height geometry and refreshes all managed window views (needsDisplay+needsLayout)- Startup:
showMainWindow()normalizes legacy compact HT frames back to full-height geometry vianormalizeModernMainWindowForHTIfNeeded() ModernMainWindowViewapplies HT-only internal reflow in draw with a Y-axis context transform (withMainContentLayoutTransform,contentLayoutScaleY)- HT internal reflow is mirrored in interaction math (
basePoint/scaledRect) so hit testing, dirty-rect invalidation, marquee frame, and Metal mini-spectrum overlay geometry stay aligned with rendered controls - Each view's
titleBarHeightcomputed property returnsborderWidth(not 0) when hidden, preserving the top border line - Library Browser uses lazy drag:
mouseDownrecordswindowDragStartPoint;mouseDraggedstarts the drag on first movement when HT is on
UI Size Mode (Both UI Modes)
UI label is UI Size with mutually-exclusive percentage rows: 50%, 90%, 100%, 105%, 110%, 115%, 125%, 135%, 150%, and 200%.
- Scale levels:
UIScaleLevelstores percentage raw values ("100","105", etc.);scaleFactorispercent / 100. - Source of truth:
WindowManager.uiScaleLevel; the legacyisDoubleSizeAPI remains a compatibility shim (truewrites 150%, reads are true for any non-100% value). - Modern UI: live toggle —
ModernSkinElements.scaleFactoris computed (baseScaleFactor * sizeMultiplier). Do NOT cachescaleFactorin aletproperty. Views should refresh renderers on.doubleSizeDidChange. - Classic UI: also live (no restart).
MenuActions.setUIScaleLevel(_:)assignsWindowManager.uiScaleLevel;applyDoubleSize(previousScale:)resizes every window in place using the exactnewScale / oldScaletransition ratio. Classic views self-scale their skin rendering from their ownbounds, so resizing is enough -- but they're layer-backed with.onSetNeedsDisplay, so a bare resize leaves a stale, stretched "ghost" of the old size (visible until the window is recomposited, e.g. by switching Spaces).applyDoubleSize()ends by walking every visible window's view tree (forceRedrawTree) settingneedsDisplay = true+displayIfNeeded()to force the repaint. - Classic Library content: the Library window remains freely stretchable, so its fonts must not derive from window bounds.
PlexBrowserView.contentScaleusesWindowManager.classicScaleMultiplierto scale bitmap text, system fonts, row height, column-header height, and matching text hit-test measurements. Manual Library resizing therefore adds space without changing text size, while changing UI Size scales the text and row metrics. - Fast switching guard:
WindowManagertracks the last applied level separately from the requested level. If another UI Size change arrives during a resize pass, it records the pending value and applies only the final requested level after the current pass finishes, using the last actually-applied scale as the ratio base. - Startup restoration:
uiScaleLevelis restored inAppStateManager.restoreSettingsState()before sub-windows are shown, so saved enlarged geometry is not double-applied during restore. Scale and window frames restore only when the saved and runningPlayerUIModevalues match exactly; Modern and Metal share controller families but have incompatible geometry and therefore count as a mismatch. A mismatch starts at 100% and uses default frames while mode-independent audio/EQ/playlist state still restores. Saved states withoutuiScaleLevelfall back from legacyisDoubleSize=trueto 150% only for an exact-mode restore. - Interaction with mode switching:
reloadUI(to:)captures the currentuiScaleLevel, collapses to 100% in the current mode before the switch, then re-applies the captured level in the target mode after windows are recreated but beforeenterCompactMode()(so a Compact-Mode capture records the enlarged layout, not a 1x one). The two UI systems have different window geometry -- and modern layout is driven by the globalModernSkinElements.sizeMultiplier-- so forcing old-mode enlarged frames onto freshly-created target-mode windows renders them distorted.prepareUIRuntimealso pinssizeMultiplierto the currentuiScaleLevelwhen entering a modern family, so modern windows are created at the right base scale rather than inheriting a stale value. The collapse runsapplyDoubleSize(), which temporarily force-docks every visible auxiliary into its canonical layout.reloadUItherefore captures every detached auxiliary frame first and restores those exact floating frames after re-applying UI Size. This applies whether the regular main window or Compact Window is active; docked windows are intentionally recomputed against the target family. - When title bars are hidden, all window drags pass
fromTitleBar: trueto allow undocking - Classic windows use drawing transform offset (
translateBy) to shift the skin image up; modern windows use conditionaltitleBarHeight
Live UI Mode Switching (Modern ↔ Classic, no restart)
Switching UI mode rebuilds only the mode-dependent window layer in-process — no app restart. AudioEngine is owned by WindowManager (not by any window), so playback, casting, playlist, current track, seek position, and play/pause survive the switch untouched; audio state is deliberately never snapshotted.
Entry points (ContextMenuBuilder / MenuActions): setClassicMode() / setModernMode(), plus the skin-driven switches selectClassicSkin / selectModernSkin / loadDefaultClassicSkin (picking a skin for the other mode switches into it). All call WindowManager.reloadUI(toModernUI:). Classic UI Size changes are also live (see the UI Size Mode section) — nothing in the UI still requires a relaunch.
`WindowManager.reloadUI(toModernUI:)` orchestration:
captureModeDependentLayout()— snapshot which mode-dependent windows are open + frames; snapshot Compact Mode.teardownModeDependentWindows()— synchronous; completion gates recreation. Orders out, callsprepareForUITeardown()on each controller (cancels tasks/timers, stops render loops, unregisters audio consumers), detaches docked children,close()+ nils the mode-dependent controllers, clears drag/snap/dock state, and flushes theObjectIdentifier-keyed geometry caches. Preserves `videoPlayerWindowController` (mode-independent — closing it stops playback/casts).- Flip
isModernUIEnabled— theshow*()paths read it to choose classic vs. modern controllers, so it must change between teardown and recreate. prepareUIRuntime(forModernUI:)—ModernSkinEngine.shared.loadPreferredSkin()entering modern; reset classic spectrum transparent-bg keys entering classic. ClassiccurrentSkinis loaded once at init and survives, so no classic reload is needed for a plain mode toggle (skin-driven classic switches load the chosen skin vialoadSkinbeforereloadUI).audioEngine.applyEQLayout(forModernUI:)— reprograms the shared fixed-21-band EQ node to the target layout (mirrors to the streaming player internally); guard-idempotent.rebuildMainMenu()via(NSApp.delegate as? AppDelegate)?.recreateModeDependentLayout(snapshot)—showMainWindow()+makeKeyAndOrderFront, restore sub-window visibility/frames viashow*(at:), re-push presentation state; restore Compact Mode last.
Audio-consumer ordering safety: consumer sets in AudioEngine (spectrum/waveform/ stereo/magnitudes) are ref-counted ([String: Int]), so a late remove from an old view's deferred deinit cannot wipe a same-id registration the replacement already made.
DEBUG-only debugRecreateModeDependentWindows() (Window menu → "Recreate Windows (Debug)") runs the same teardown/rebuild in the same mode — the leak/lifecycle test that de-risks the live switch. Requires a debug build: ./scripts/kill_build_run.sh --debug.
Edition persistence policy
AppPersistence is the single edition-neutral seam for UI-mode and session-geometry persistence. The full edition returns forcedUIMode == nil and leaves every key unchanged. A build compiled with EDITION_CUSTOM supplies EditionPolicy.forcedUIMode and EditionPolicy.preferenceNamespace. Treat these as one required isolation package: forcing a downstream mode without namespacing its session state can let the full edition decode that unknown mode through the legacy Classic/Modern fallback and accept incompatible geometry as an exact match.
PlayerUIMode.stored(in:)returns the forced mode before consulting any defaults domain,
including the -uiMode launch argument. persist(in:) writes nothing in a forced edition.
- EQ layout initialization also resolves through
PlayerUIMode.stored()rather than reading
the shared modernUIEnabled mirror. Use the distinct usesModernEQLayout policy when adding a downstream mode: it may intentionally differ from usesModernControllers (for example, custom controllers with the 21-band modern EQ).
WindowManager.uiModeis seeded once and held in memory, so launch-argument defaults cannot
pin or revert later live switches. Both the setter and reloadUI(to:) reject a target that differs from the forced mode; the reload guard is required because rejecting only the setter would still let teardown/recreation use the invalid target.
AppPersistence.key(_:)scopes onlyrememberStateEnabled,savedAppState, and the legacy
*WindowFrame channels. Accounts, server configuration, media data, skins, and other intentionally shared preferences remain unscoped.
- The full edition uses identity keys and retains its legacy-frame behavior. A scoped edition
never resolves or clears the unscoped frame/session keys, so it starts with fresh edition-specific UI geometry instead of migrating an ambiguous snapshot.
WindowManager.saveWindowPositions()is a compatibility-only legacy writer in NullPlayer;
current launch restoration uses AppStateManager, and restoreWindowPositions() has no NullPlayer caller. The namespaced legacy channel remains for downstream consumers and migration compatibility.
Compact Mode
Compact Mode is a WindowManager state transition, not a second main window style.
Key implementation details:
WindowManager.enterCompactMode(revealWindow:)snapshots regular windows, establishes the compact window's presence on the current Space, detaches docked child windows, orders regular windows out, switches the app to.accessory, re-activates NullPlayer (NSApp.activate) so the.accessorytransition doesn't hand the Space to a fullscreen app, creates the status item, and owns the Compact Mode state (regular,compactVisible,compactHidden). See the Spaces gotchas below.Windows/CompactMode/CompactModeWindowController.swiftowns a private browser controller (PlexBrowserWindowControllerorModernLibraryBrowserWindowController) and callssetCompactMode(true). Do not replace this with a custom compact-only view; Compact Mode must keep the same browser compact surface and embedded compact player bar behavior as the old implementation.- Compact updates are forwarded through the compact controller (
updateCompactBarTime,updateCompactBarTrack,updateCompactBarPlaybackState) so playback state stays live while the regular windows are hidden. - The status item left-click toggles compact visibility. Right-click opens the compact menu. Hidden compact mode remains in
.accessoryuntil explicitly exited. - Exiting Compact Mode removes the status item, switches back to
.regular, rebuilds the main menu asynchronously, restores the regular window snapshot, then reattaches/restores docked windows. It then bounces the activation policy (.accessory→.regularback-to-back) to force the menu bar to re-own, and callsreassertRegularActivation()(activate + make the main window key + re-apply the Dock icon) on the next runloop turn. See the.accessory → .regularmenu-bar gotcha below. - Entry points: the Compact Mode item in the main-window right-click menu and the
Windowsmenu (placed on its own line, separated, above Always On Top), plus the modern main window's CP toggle button (btn_compact). The CP button click forces the button's active (on) highlight anddisplay()s it synchronously before deferringtoggleCompactMode()to the next runloop —enterCompactModeis heavy synchronous AppKit work (activation-policy switch, window teardown, status-item creation) that, run inline inmouseUp, would block the press repaint and beachball. The fallback toggle-button renderer keys its highlight off the on-state only (notisPressed), socompactButtonActivatingforces the on-look during the transition.
Placement rules:
- The compact window is positioned by
CompactModeWindowController.position(anchoredTo:). - On show, align the compact window's top edge to the current screen
visibleFrame.maxY; do not leave a top margin/gap below the menu bar. - The window is centered exactly under the status-item icon (
origin.x = iconCenterX - width/2) with no clamping. Do not clamp the origin back onto the screen — clamping a wide window centered under a near-right-edge icon is exactly what jammed it against the right margin. If the icon sits near a corner, the window legitimately sits near that corner. - No fallback placement. There is no top-right / screen-center / "button not available yet" fallback. The reveal is gated on a settled anchor (see Compact window reveal positioning); if the anchor never resolves, the window stays unrevealed rather than appearing at a guessed spot. The dead-end is surfaced — not silently swallowed:
startAnchorDiagnosticTimerruns in all builds andNSLogs after ~2.5s with no reveal (and additionallyassertionFailures in DEBUG). This is the only release-safe signal for the rare case wherebutton/button.windowis nil atshow()time (status-item layout churn), so the frame observers never attach and nothing else could reveal or report the alpha-0 window. Do not "fix" that case by revealing at a guessed position — a guessed spot is the right-edge bug this whole design exists to avoid. - Keep the compact width based on the browser surface's
minimumCompactContentWidth. Do not force a narrower hard-coded width, and do not abbreviate/truncate browser tab labels just to make the window thinner. - Use
.moveToActiveSpacefor the compact window, not.canJoinAllSpaces. Showing/hiding from the status item should reveal the compact window on the user's current desktop/Space, not stick to a previous Space or appear everywhere. - The setup-time
position(anchoredTo: nil)call sizes only (once, whileneedsInitialSizing) and never sets the origin. Usedisplay: falsefor hidden frame changes and delay shadow/key/display until the final centered frame is applied on reveal.
Spaces / virtual-desktop gotchas (hard-won)
These are subtle and only reproduce with multiple Spaces / a fullscreen app on another desktop. Verify any change to the enter/exit sequence against that setup.
- `.accessory` transition steals the Space.
NSApp.setActivationPolicy(.accessory)makes macOS resign NullPlayer and activate the next app in the stack. If that app is in native fullscreen on another Space (e.g. Console), macOS switches the user to that Space, and the.moveToActiveSpacecompact window then follows. The diagnostic signature is aNSWorkspace.didActivateApplicationNotificationfor another app firing immediately after the policy change. Fix: callNSApp.activate(ignoringOtherApps:)right aftersetActivationPolicy(.accessory)so NullPlayer stays frontmost on the current Space. The exit path already does this aftersetActivationPolicy(.regular); entry must mirror it. (Merely ordering the compact window front/key does not prevent the handoff — the policy change yields activation regardless.) - Re-activation needs a current-Space window to land on. Because the regular windows are ordered out before the policy change, call
compactWindowController?.establishPresenceOnActiveSpace()(orders the invisible alpha-0 compact window front on the current Space) beforeorderOutRegularWindows(), so theNSApp.activateabove has a NullPlayer window on the current Space to focus. - Never `orderOut`/`orderFront` a native-fullscreen window. Doing so forces macOS to switch to that window's own Space to run the show/hide animation.
orderOutRegularWindows,orderOutOrphanedAppWindows, andrestoreRegularWindowSnapshotall skip windows whereisInNativeFullScreen(_:)(styleMask.contains(.fullScreen)); leave them untouched on their Space. - `.accessory → .regular` menu bar does not rebuild while the app stays active — bounce the policy. On exit two things lag behind the transition: (1) macOS rebuilds the Dock tile and substitutes the generic executable icon, and (2) the whole menu bar stays owned by the previously-active regular app — the system menu bar shows another app's menus (not empty NullPlayer menus), and it persists until the user minimizes/restores a window. Root cause: the menu bar only re-owns on a genuine activation transition, but NullPlayer is already the active app throughout the round trip (exiting from the status-item menu keeps it active; the launch-straight-into-Compact path launches active and
enterCompactModere-activates it under.accessory). In that already-active state both `NSApp.activate` *and* `NSApp.deactivate()` are no-ops — confirmed by diagnostics: afterNSApp.deactivate(),NSApp.isActiveis stilltrueand the menu bar still shows the other app. The only reliable trigger is bouncing the activation policy:NSApp.setActivationPolicy(.accessory); NSApp.setActivationPolicy(.regular)back-to-back in the deferred restore block, which forces AppKit to re-own the menu bar.exitCompactModedoes this, then re-asserts the rebuilt menu +reassertRegularActivation()(activate +makeKey+ Dock icon) on the next runloop turn. Verified with screenshots across launch-into-compact and live-toggle exits, app active and inactive at exit. TherestoreRegularWindows: falselive-UI-switch path skips this (it re-enters compact immediately). The bounce has one cost: it makes macOS rebuild the Dock tile asynchronously, and that rebuild can land after the re-assert and stamp the tile with the generic executable icon (a black tile literally labelled "exec").exitCompactModetherefore re-appliesrestoreDockIconImage()at+0.3sand+0.8safter the bounce so it wins that race. This is only observable on the un-bundled dev binary (kill_build_run.sh), which has no bundle icon to fall back to; a shipped.apprebuilds from itsCFBundleIconFile. Both fixes verified with Dock screenshots (bare binary and a real.appbundle). - Compact Mode needs its own Quit — `.accessory` has no menu bar or Dock icon. The only in-app quit while compact is the status-item right-click menu, so
presentCompactStatusMenuincludes a Quit nullPlayer item (NSApplication.terminate(_:)). Without it users are forced to Activity Monitor / force-quit, which bypassesapplicationWillTerminate → AppStateManager.saveState()and loses that session's persisted settings (e.g. the selected skin restores to a stale value on next launch, because the stale AppState blob overwrites the newer UserDefaults skin key). A cleanterminaterunssaveStateand the skin persists — verified.
Live UI switch (Classic↔Modern) while in Compact Mode
reloadUI(toModernUI:) must not naively call exitCompactMode() then enterCompactMode():
exitCompactModerestores asynchronously (state stays.exitinguntil a deferred block), so a synchronous re-enter hits the.regularguard and is silently dropped.exitCompactModeis completion-based; run the teardown/rebuild/re-enter inside the completion.- Pass
exitCompactMode(restoreRegularWindows: false)on this path: re-showing the still-hidden.managedregular windows would pull the user to whatever Space they live on. Derive the rebuild snapshot from the pre-compact capture (modeDependentLayout(from: regularWindowSnapshot)) instead of the live (hidden) windows. enterCompactMode()re-capturesregularWindowSnapshotfrom the live windows, which loses hidden mode-independent app panels (they survive teardown but stay hidden). Carry those fields forward withreapplyModeIndependentWindows(from:)after the rebuild. The video player and debug console are exempt from Compact Mode hiding and stay visible throughout.
Compact window reveal positioning
The reveal is event-driven with no fallback (this replaced the old retry-polling budget, which revealed at a guessed position once the ~0.3s budget expired). In show(anchoredTo:) for a not-yet-visible window: keep it at alpha 0, register NSWindow.didMove/.didResize observers on button.window first, then do a synchronous isStatusAnchorReady check. Reveal exactly once (guarded by hasRevealed) the instant the anchor is ready — no attempt cap, no timeout. AppKit always posts a move when it slots the icon into the menu bar.
isStatusAnchorReadyrequires both signals: (1) Y — menu-bar proximity (buttonScreenRect.maxYnearscreen.frame.maxY), and (2) settled X — the icon's rect is not flush against either screen horizontal edge (statusItemEdgeInset). During the.accessoryentry churn a brand-newNSStatusItemis briefly reported flush in the screen's top-right corner (right edge ==screen.frame.maxX) before AppKit slides it into its real slot. A real status item never sits in a corner (Control Center et al. are always to its right), so a flush-edge X means "still laying out — keep waiting for the next move notification." Centering under that transient corner X is what put the window hard against the right edge — the bug that survived PRs #306/#307, which chased the rarely-fired fallback rather than the bad measurement. This same check also rejects the older near-left-origin placeholder ("left-aligned" bug). It is correct ~90% of the time without the check; the failure mode is the slow.accessorychurn (e.g. entering Compact Mode with all windows open) losing the layout race.- Re-anchor only on the initial reveal and on
NSApplication.didChangeScreenParametersNotification(display reconfig). AfterhasRevealed, incidental status-buttondidMoves are ignored so a menu-bar relayout (another app adding/removing an item) can't snap a user-dragged compact window back under the icon. - No-fallback de-risk: a DEBUG-only timer fires
assertionFailure/NSLogif the anchor hasn't resolved after ~2.5s, so a genuinely stuck invisible window surfaces loudly in development rather than silently. It is purely diagnostic — never a positional fallback. - Tear down both frame observers, the display-config observer, and the diagnostic timer in
hide()anddeinit. - The logic lives entirely in the shared
CompactModeWindowController(modernUIonly selects the embedded browser surface), so classic, modern, and metal skins all reveal through this one path — there is no mode-specific positioning to keep in sync.
Compact Window
Compact Window is the free-floating sibling of menu-bar Compact Mode. It reuses CompactModeWindowController and the embedded browser compact surface, but it must not change activation policy or create a status item.
Implementation rules:
WindowManager.compactWindowEnabledis the source of truth and persists under
compactWindowEnabled. It is mutually exclusive with compactModeEnabled.
- Entering Compact Window hides only the main window, records whether main was visible,
creates the shared compact controller if needed, and calls showFloating(level:). Secondary windows stay visible and keep their frames.
CompactModeWindowController.showFloating(level:)uses.normallevel by default,
.managed / .fullScreenAuxiliary collection behavior, immediate alpha-1 reveal, and frame persistence via compactWindowFrame. Do not run status-anchor observers, display-reconfig anchoring, diagnostic timers, or .statusBar level juggling in floating mode.
showMainWindow(reveal:)must not reveal main whilecompactWindowEnabledis true. Generic
app reopen handling should call WindowManager.handleAppReopen(), which focuses/re-shows Compact Window and keeps main ordered out. This prevents returning from another Space or a fullscreen app from reopening both Compact Window and the main window.
- The compact-bar update forwarders must run for
compactModeEnabled || compactWindowEnabled
so track/time/play state stays live in both variants.
- The classic and modern compact player bars should start a window drag from non-control
regions when compactWindowEnabled is true. Keep playback buttons, seek, volume, close/minimize hit targets consuming their own events. Menu-bar Compact Mode remains anchored and should not become draggable from the title/player bar.
- Live UI switching should exit Compact Window, rebuild the mode-dependent window layer, then
re-enter Compact Window so the embedded classic/modern compact surface matches the new mode.
Library and Compact Window visual backdrops (Modern/Metal)
The regular Library and Compact surfaces each support a durable visual mode: Off, Cava, Art, or Cava + Art. They persist independently as libraryBackdropMode and compactBackdropMode. Controls live under Visuals > Library Window or Visuals > Compact Window and in the corresponding surface's context menus. Classic resolves both modes to Off. Modern and Metal default both windows to Cava + Art on first use; persisted window-specific selections still win. When those keys are absent on upgrade, the legacy showBrowserArtworkBackground value seeds both: enabled maps to Cava + Art and disabled maps to Cava. Reset All Visualization Preferences clears both new mode keys and restores Cava + Art.
Implementation rules:
ModernLibraryBrowserWindowControllerinstalls a clear container as the window content view.
CompactBackdropView and ModernLibraryBrowserView are siblings in both regular and Compact presentations, with the backdrop positioned below the browser. Do not make the backdrop a child of the browser; the browser's layer clipping and redraw behavior would clip or erase it.
- Size the backdrop from the content container's
boundson creation and every layout pass. Do not
copy the browser's frame: an offset or partially sized browser frame can restrict Cava to one side of the compact window. Keep the backdrop autoresizing in width and height. Propagate the browser's sharpCorners into the backdrop layer mask whenever docking changes so both siblings square the same joined corners.
- When a backdrop is first selected from Off, mark the complete container subtree for display and
layout immediately and again on the next main-run-loop turn. This transition changes the browser from an opaque cached surface into a translucent sibling composition; invalidating only the browser can leave a horizontal cached region covering the backdrop until relaunch.
- The browser remains above the backdrop and uses translucent background/panel fills while a
backdrop is active. Text, controls, borders, and hit testing remain browser-owned and fully interactive. CompactBackdropView.hitTest returns nil.
- Art and Cava + Art must use the existing legacy browser artwork renderer and its list-area
geometry. Never add a full-window/aspect-fill artwork sibling: it duplicates the legacy image and changes its established size. Art creates no backdrop sibling; Cava + Art draws the one legacy artwork image in the browser above the Cava sibling. Current-track and selection artwork loads share one display generation: concurrent work may fill caches, but only the newest request may assign the visible currentArtwork.
- Cava uses
CavaPresenter(scope: .libraryWindow)in the regular Library and
CavaPresenter(scope: .compactWindow) in Compact. It draws into the backdrop's complete bounds. Both scopes default to 64 bars. Library's first-use mode is Mono. Compact's first-use mode is Stereo and its smoothing is Smooth (noiseReduction = 0.80). In backdrop Mono, mirror the combined spectrum center-out across both horizontal halves; a one-way frequency sweep can look like half the surface is unused. Start the audio consumer only while Cava is selected and the owning window is visible, non-miniaturized, and on-screen; stop it for Off, Art, hide, occlusion, and teardown.
- Backdrop Cava menus omit standalone-only Transparency and Close actions. Library and Compact
tuning/colors use cava.libraryWindow.* and cava.compactWindow.* keys and participate in visualization reset independently.
- Every Compact Window visuals menu entry point must check
AppCapabilities.supports(.compactWindowVisualsMenu): the top-level Visuals submenu, compact surface context-menu injection, the shared menu builder, and its selection action. This is a UI visibility seam only; backdrop rendering and persisted mode resolution remain independent.
Cover Flow (Library browser, all skin families)
Cover Flow is a 3D, GPU-composited carousel of music, movie, and TV artwork shown in place of the library list, toggled by a FLOW button. It is a visual lens over the browser's current displayItems, not a separate query. It ships in Modern, Metal, and Classic at once.
Shared component — Windows/ModernLibraryBrowser/CoverFlowView.swift (used by both browsers):
- A layer-backed
NSViewwith acontainerLayerwhosesublayerTransformapplies perspective
(m34 ≈ -1/900). Each cover is a CoverLayer (a CALayer with the artwork as contents, a gradient-masked flipped reflection sublayer, and a solid-color placeholder). Never render placeholders with NSImage.lockFocus — that bitmap path was a main-thread hang; use the layer's backgroundColor. The Back cover uses one cheap CATextLayer.
- Source-agnostic input `CoverFlowItem { id, title, subtitle, artwork() /sync cache hit/,
loadArtwork() /async/, isBack }. Cover size is keyed to the view **height** (minus a reserved bottom label band); a wider window shows **more** covers (virtualRadius grows with width, capped by maxRadius), not bigger ones. Classic sets labelPlacement = .belowCenteredCover` so its freely stretchable/taller Library window keeps the centered title/subtitle visually attached to the artwork rather than stranded at the bottom edge; Modern keeps the standard bottom band.
- Interaction: continuous 1:1 scroll snapped to the nearest cover on release, using the dominant
horizontal/vertical axis so trackpads and ordinary mouse wheels both work, with a maxLead cap so a momentum fling can't outrun artwork loads; Left/Right arrows; click a side cover to center it, click the centered cover to fire onActivate(index). Artwork loads are throttled to covers near center, ordered center-out, and each index loads at most once (attemptedIndices) so a nil-artwork cover never re-triggers on every layout pass. The centered item's name/subtitle render in the reserved bottom band via two CATextLayers. onApproachingEnd fires once per item count when the center enters the final preload window, allowing a paginated host to append its next page.
Host wiring — mirrored in ModernLibraryBrowserView (Modern+Metal) and PlexBrowserView (Classic):
- An
isCoverFlowModetoggle mirroringisArtOnlyMode(mutually exclusive with it). Modern draws a
FLOW boxed toggle next to ART in the source bar. Classic also places FLOW in the source bar's ART/F5 accessory cluster, using its bitmap-text active/inactive treatment; it must not consume tab-row width or present as another browse tab.
- The overlay is a subview sized to the list content rect (
embeddedHistoryContentRect/
embeddedContentRect), added above the list and below the top chrome. In cover flow the draw path fills nothing over the list area so the window background (translucent over a Cava backdrop, opaque otherwise) shows through — do not add a second contentFill scrim or Cava disappears.
- Tree navigation: cover flow keeps a focus stack (
coverFlowFocusStack).isCoverFlowItem
covers artists, albums, folders, tracks, movies, shows, seasons, and episodes across every supported source. Activating an album, track, movie, or episode plays it; any other container (hasChildren) drills in — coverFlowDrillIn ensures the row is expanded (guarded by isExpanded, since toggleExpand toggles) and pushes its id; the visible level is the container's direct children (indentLevel == parentLevel+1). TV navigation therefore follows show → season → episode. A synthetic ‹ Back cover at index 0 pops. Re-centering: coverFlowCenterFirstChild on drill-in, coverFlowPendingCenterId on back — retained across rebuilds because children may load asynchronously. Search-result shows preserve this hierarchy; music containers keep their existing search-navigation behavior.
- Rebuilds must be coalesced.
displayItems.didSetcallsscheduleCoverFlowRebuild()(one
DispatchQueue.main.async pass), never a synchronous rebuild — buildArtistItems and peers mutate displayItems many times per reload, and a synchronous carousel rebuild per mutation beachballs.
- Root eligibility must match the items the carousel can actually show. Normal modes use eligible
level-0 rows; Search uses eligible level-1 rows beneath its synthetic category headers. Do not enable FLOW merely because an ineligible top-level container (for example a server playlist) has an expanded eligible descendant, or the user gets an empty carousel. Local Artists/Albums remain paginated: wire onApproachingEnd to the same next-page append logic used by list scrolling, but only while the Cover Flow focus stack is at its root.
- Artwork loaders reuse the per-source loaders behind
loadArtworkForSelection; Plex, Jellyfin,
and Emby video items use their native posters, while local video items try embedded artwork from the file (shows and seasons use their first episode). Local track/album resolution (MediaLibraryStore lookups) happens inside the async loader via Task.detached, never synchronously in the item-mapping pass. Teardown removes the cover flow view in prepareForUITeardown; toggling the mode off clears the focus stack.
Window Docking
Complex snapping logic in WindowManager:
- Multi-monitor: Screen edge snapping is skipped if it would cause docked windows to end up on different screens
Snap to Defaultcenters main window on its current screen (not always the primary display)- Coordinated minimize: uses
addChildWindow/removeChildWindowinwindowWillMiniaturize/windowDidDeminiaturizeto temporarily make docked windows children of the main window so they animate into the dock together. Child relationships are removed on restore. - Center stack collapse:
slideUpWindowsBelow(closingFrame:)inWindowManagerslides docked windows up when a stack window is hidden. Called fromtoggleEqualizer/Playlist/Spectrum/Waveform— capture the frame BEFOREorderOut, then call it. Uses BFS overdockThreshold-adjacent windows (by vertical gap + horizontal overlap). Must setisSnappingWindow = trueduring moves to prevent the docking feedback loop.
Hold-Duration Drag Model
Dragging a docked window uses a time-based mode determined at the first mouseDragged event:
| Hold duration | Drag mode | Behaviour | |
|---|---|---|---|
< 400 ms (holdThreshold) | .separate | Dragged window detaches; peers stay connected to each other | |
| ≥ 400 ms | .group | All connected windows move together |
Implementation details:
DragModeenum:.pending(not yet decided) /.separate/.group- Hold timing can be primed at
mouseDown(windowWillPrimeDragging) before actual drag start for lazy-drag views (for example HT-on library browser) - Mode resolves on first
windowWillMoveviadetermineDragMode(holdStart:currentTime:threshold:isWindowLayoutLocked:)(pure static, unit-tested) - If
isWindowLayoutLocked == true, drag mode is forced to.groupregardless of hold duration - Separate mode: peers are restored to their pre-drag origins before the dock is broken
- Group mode: connected windows move using stored offsets from drag start to prevent drift; child windows of the dragging window are skipped (AppKit moves them automatically); group top is clamped so no window goes off-screen
- Mid-drag window close:
NSWindow.willCloseNotificationobserver cleans up hold state and clears highlights - Mid-flight drag (AppKit-initiated, no prior
mouseDown): always.groupmode (override) - Programmatic moves are filtered by
shouldTreatMoveAsDrag(...)so startup restore/snapping does not arm drag state or post false highlights - Connected window highlight: at
mouseDown, all peer windows receive awhite @ 15% opacityoverlay viaconnectedWindowHighlightDidChangenotification. Cleared when drag ends or.separatemode is resolved. All 10 dockable views (5 classic + 5 modern) observe this notification. isMovingDockedWindowsflag prevents re-entrantwindowWillMovecalls while peers are being repositioned
Related Documentation
- non-retina-fixes skill - Fixes for rendering artifacts on 1x displays (blue lines, tile seams, text shimmering)
External References
- Winamp Skin Archive - Community skin downloads