<< All versions

Skill v1.0.3

currentAutomated scan100/100
ad-repo/nullplayer/ui-guide

~3 modified

──Details
PublishedSeptember 17, 2026 at 05:25 AM
Content Hashsha256:deda0b3b7dee3a06...
Git SHA92d0107b5971
Bump Typepatch
Compare with v1.0.2
──Files
Files (1 file, 76.9 KB)
SKILL.md76.9 KBactive
SKILL.md · 1079 lines · 76.9 KB

version: "1.0.3" 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:

swift
context.translateBy(x: 0, y: bounds.height)
context.scaleBy(x: 1, y: -1)

Text requires counter-flip (NSString draws upside-down after the transform):

swift
context.saveGState()
let centerY = textY + fontSize / 2
context.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:

swift
var scaleFactor: CGFloat {
bounds.width / originalWindowSize.width
}
override func draw(_ dirtyRect: NSRect) {
let scale = scaleFactor
context.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 skill
context.interpolationQuality = .low
if scale != 1.0 {
let scaledWidth = originalWindowSize.width * scale
let scaledHeight = originalWindowSize.height * scale
let offsetX = (bounds.width - scaledWidth) / 2
let offsetY = (bounds.height - scaledHeight) / 2
context.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:

swift
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 PLEDIT tiled 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) + 50 before applying frame updates.

effectiveWindowSize expands in both dimensions in skin space:

swift
private var effectiveWindowSize: NSSize {
let scale = scaleFactor
let effectiveWidth = bounds.width / scale
let effectiveHeight = bounds.height / scale
return NSSize(width: effectiveWidth, height: max(originalWindowSize.height, effectiveHeight))
}

Hit Testing (Scaling Mode)

Convert view coordinates to skin coordinates:

swift
private func convertToWinampCoordinates(_ point: NSPoint) -> NSPoint {
let scale = scaleFactor
let scaledWidth = originalWindowSize.width * scale
let scaledHeight = originalWindowSize.height * scale
let offsetX = (bounds.width - scaledWidth) / 2
let offsetY = (bounds.height - scaledHeight) / 2
let unscaledX = (point.x - offsetX) / scale
let unscaledY = (point.y - offsetY) / scale
let winampY = originalWindowSize.height - unscaledY
return NSPoint(x: unscaledX, y: winampY)
}

Skin File Structure

.wsz files are ZIP archives containing:

FilePurpose
MAIN.BMPMain window background
CBUTTONS.BMPTransport buttons
TITLEBAR.BMPTitle bar sprites
SHUFREP.BMPShuffle/repeat/EQ/playlist toggles
POSBAR.BMPPosition slider
VOLUME.BMPVolume slider
NUMBERS.BMPTime display digits
TEXT.BMPMarquee font
EQMAIN.BMPEqualizer (275x315)
PLEDIT.BMPPlaylist sprites
PLEDIT.TXTPlaylist 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:

swift
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:

swift
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.height
let 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...9 before 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 to bounds.height, INCLUDING the bottom-corner regions, so the outer gold trim runs continuously down each side.
  • `drawPlaylistStyleBottomBorder` — rotated leftSideTile strip inset between the side borders (x = 12 to bounds.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 leftCorner sprite (not by drawing the original rightCorner). The original rightCorner artwork 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 original rightCorner are re-drawn on top from sprite coords (167, 3, 9, 9) and (158, 3, 9, 9) (with +21 y 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-menu NSMenuItem instances.
  • 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.
  • Keep entries in the menu that owns their action, not merely the object they configure. In

.winampModern, Text Size is adjacent to UI Size in Windows, and named skin-owned windows form a flat section after NullPlayer-owned windows; neither belongs in Skins. Filter the skin block through the reconciled surface catalog so playlist, EQ, library, video, and visualization never acquire duplicate rows. NullPlayer's Compact Mode and Compact Window are omitted because a .wal skin owns its compact/shade layouts.

  • For Sonos room selection UX, use SonosRoomCheckboxView when persistent-open submenu behavior is required.
  • For library-browser column visibility menus, use ColumnVisibilityCheckboxView for persistent-open checkbox rows. Keep column preferences mode-scoped: Modern uses BrowserVisible*Columns; Classic uses ClassicBrowserVisible*Columns.

Dockable Center-Stack Windows

Main, EQ, Playlist, Spectrum, Waveform, Audio Analysis, PeppyMeter, Flow, and Sonos Rooms 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; Sonos Rooms uses a double-height baseline and preserves a taller restored list
  • Saved frames are restored through WindowManager rather 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/ so WindowManager can 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 on borderWidth > 0 && !adjacentEdges.isEmpty and 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:

  1. Shared non-UI logic in a neutral folder (for example Waveform/)
  2. Classic chrome in Windows/...
  3. Modern chrome in Windows/Modern...
  4. Registration and docking behavior in WindowManager

Sonos Rooms uses a shared controller/content view with separate Original/Metal chrome and a .wal hosted-surface adapter. Its scrollable list must leave Refresh and casting controls in a fixed footer. Explicitly lay out controls on first show and resize, including when the room list is empty, and match native AppKit control appearance to the skin's background. The implementation, volume semantics, lifecycle, and rendering fixtures are owned by the Sonos casting skill.

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:

  1. Close button → set pressed state, return.
  2. Any clickCount == 2 action (e.g. Flow's direction toggle, Spectrum's quality cycle) → return.

This must come before the fallthrough or it becomes dead code.

  1. Title bar → start a title-bar drag, return.
  2. 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 / windowDragStartPoint state set in mouseDown
  • mouseDragged that moves the window origin through WindowManager.shared.windowWillMove(_:to:)
  • mouseUp that calls WindowManager.shared.windowDidFinishDragging(_:) and clears isDraggingWindow

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.lastPlexBrowserFrame caches 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): saves wm.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=12 selects bottom half (active)
  • sourceRect y=0 selects top half (inactive)
swift
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:

StatesSource region
0-13x = 13 + state * 15, y = 164, 15x63
14-27x = 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:

swift
// All playlist text uses bitmap font from TEXT.BMP
// CGImage is cached outside draw cycle to prevent cross-window interference
private 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 flipping
private 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:

swift
// Timer-based marquee offset (8Hz update rate)
private var marqueeOffset: CGFloat = 0
private var currentTrackTextWidth: CGFloat = 0
// In drawTrackText(), current track uses marqueeOffset for scrolling
let 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.

swift
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:

swift
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:

swift
override func viewDidMoveToWindow() {
// Auto-select the currently playing track when playlist opens
if 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:

swift
private func containsNonLatinCharacters(_ text: String) -> Bool {
for char in text {
switch char {
case "A"..."Z", "a"..."z", "0"..."9":
continue
case " ", "\"", "@", ":", "(", ")", "-", "'", "!", "_", "+", "\\", "/",
"[", "]", "^", "&", "%", ".", "=", "$", "#", "?", "*":
continue
default:
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:

swift
// 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 context
for i in 0..<(charWidth * charHeight) {
let offset = i * 4
let g = pixels[offset + 1] // Green channel = brightness
// Skip transparent and magenta background pixels
if a == 0 || isMagenta { continue }
// Convert to white using green channel as brightness
pixels[offset] = g // R
pixels[offset + 1] = g // G
pixels[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

  1. Using `bounds` instead of `drawBounds` after scaling transform
  2. Forgetting text counter-flip - text appears upside-down
  3. Hit testing in view coords - must convert to skin coords first
  4. Non-tile-aligned widths - causes sprite interpolation artifacts
  5. Drawing over skin sprites - they already contain labels
  6. Using blend modes for color conversion - causes sub-pixel artifacts when scaling; use offscreen pixel manipulation instead
  7. 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)
  8. Setting `NSTextField.backgroundColor` alone - it is inert unless drawsBackground = true
  9. Trusting a requested window frame without checking the result - required Auto Layout constraints in the content tree can derive a larger minimum size, causing setFrame to return a different frame silently. Hidden views still contribute constraints; isHidden does not remove them, so remove the view from the hierarchy when its constraints must stop applying. Log the requested frame next to window.frame before theorising. See winamp-modern-skin-guide/reference/components/video.md for the worked 395pt case.

Key Files

FilePurpose
Skin/SkinElements.swiftAll sprite coordinates
Skin/SkinRenderer.swiftDrawing code
Skin/SkinLoader.swiftWSZ loading, BMP parsing
Skin/MarqueeLayer.swiftMain window marquee (bitmap font, CALayer-based)
Windows/Playlist/PlaylistView.swiftPlaylist view with bitmap font rendering
Windows/*/View.swiftWindow views
App/WindowPlacement.swiftThe single definition of "on screen" — pure NSRect statics

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

FilePurpose
Visualization/AudioReactiveUniforms.swiftAudio data struct for shaders
Visualization/ShaderManager.swiftMetal pipeline management
Visualization/ArtworkVisualizerView.swiftMTKView rendering
Windows/ArtVisualizer/ArtVisualizerWindowController.swiftWindow controller
Windows/ArtVisualizer/ArtVisualizerContainerView.swiftWindow chrome

Effect Presets

EffectDescription
CleanOriginal artwork, no effects
Subtle PulseGentle brightness/scale pulse on beats
Liquid DreamsFlowing displacement with color shifts
Glitch CityHeavy RGB split and block glitches
Cosmic MirrorKaleidoscope with chromatic aberration
Deep BassIntense displacement on low frequencies

Keyboard Controls (when focused)

  • Escape - Close window (or exit fullscreen)
  • Enter - Toggle fullscreen
  • Left/Right - Cycle through effects
  • Up/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:) in WindowManager. The closing frame must be captured BEFORE orderOut.

Key Files

FilePurpose
Visualization/SpectrumAnalyzerView.swiftMetal-based spectrum view component
Visualization/SpectrumShaders.metalGPU shaders for bar rendering
Visualization/FlameShaders.metalFire mode shaders (compute + render)
Visualization/CosmicShaders.metalJWST mode shader
Visualization/ElectricityShaders.metalLightning mode shader
Visualization/MatrixShaders.metalMatrix mode shader
Visualization/SnowShaders.metalSnow mode shader
Windows/Spectrum/SpectrumWindowController.swiftWindow controller
Windows/Spectrum/SpectrumView.swiftContainer view with skin chrome

Quality Modes

ModeDescription
WinampDiscrete color bands from skin's viscolor.txt with floating peak indicators, 3D bar shading, and segmented LED gaps
EnhancedRainbow LED matrix with gravity-bouncing peaks, warm amber fade trails, 3D inner glow cells, and anti-aliased rounded corners
UltraMaximum fidelity seamless gradient with smooth exponential decay, perceptual gamma, and warm color trails
FireGPU fire simulation with audio-reactive flame tongues in 4 color styles
JWSTDeep space flythrough with 3D star field, vivid JWST diffraction flares as intensity indicators, and rare giant flare events
LightningGPU lightning storm with fractal bolts mapped to spectrum peaks and multiple color schemes
MatrixFalling digital rain with procedural glyphs and selectable color/intensity styles
SnowAudio-reactive layered snowfall with smooth flurry-to-blizzard intensity shifts

Decay/Responsiveness Modes

Controls how quickly spectrum bars fall after peaks:

ModeRetentionFeel
Instant0%No smoothing, immediate response
Snappy25%Fast and punchy (default)
Balanced40%Good middle ground
Smooth55%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 via normalizeModernMainWindowForHTIfNeeded()
  • ModernMainWindowView applies 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 titleBarHeight computed property returns borderWidth (not 0) when hidden, preserving the top border line
  • Library Browser uses lazy drag: mouseDown records windowDragStartPoint; mouseDragged starts 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%, 175%, 200%, 250%, and 300%.

UIScaleLevel.nearest(toScaleFactor:) maps an arbitrary multiplier onto the ladder (ties to the larger level, ends clamp). It exists for callers that think in factors rather than menu rows — a .wal skin's setScale, which is answered by this system and not by a scale of the skin's own; 175/250/300 are on the ladder because that skin's configurator offers them.

  • Scale levels: UIScaleLevel stores percentage raw values ("100", "105", etc.); scaleFactor is percent / 100.
  • Source of truth: WindowManager.uiScaleLevel; the legacy isDoubleSize API remains a compatibility shim (true writes 150%, reads are true for any non-100% value).
  • Modern UI: live toggle — ModernSkinElements.scaleFactor is computed (baseScaleFactor * sizeMultiplier). Do NOT cache scaleFactor in a let property. Views should refresh renderers on .doubleSizeDidChange.
  • Classic UI: also live (no restart). MenuActions.setUIScaleLevel(_:) assigns WindowManager.uiScaleLevel; applyDoubleSize(previousScale:) resizes every window in place using the exact newScale / oldScale transition ratio. Classic views self-scale their skin rendering from their own bounds, 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) setting needsDisplay = 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.contentScale uses WindowManager.classicScaleMultiplier to 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: WindowManager tracks 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: uiScaleLevel is restored in AppStateManager.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 running PlayerUIMode values 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 without uiScaleLevel fall back from legacy isDoubleSize=true to 150% only for an exact-mode restore.
  • App-state policy: See ../app-state/SKILL.md for session restore scope, preference ownership, edition scoping, and reset rules.
  • Interaction with mode switching: reloadUI(to:) captures the current uiScaleLevel, 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 before enterCompactMode() (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 global ModernSkinElements.sizeMultiplier -- so forcing old-mode enlarged frames onto freshly-created target-mode windows renders them distorted. prepareUIRuntime also pins sizeMultiplier to the current uiScaleLevel when entering a modern family, so modern windows are created at the right base scale rather than inheriting a stale value. The collapse runs applyDoubleSize(), which temporarily force-docks every visible auxiliary into its canonical layout. reloadUI therefore 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: true to allow undocking
  • Classic windows use drawing transform offset (translateBy) to shift the skin image up; modern windows use conditional titleBarHeight

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:

  1. captureModeDependentLayout() — snapshot which mode-dependent windows are open + frames; snapshot Compact Mode.
  2. teardownModeDependentWindows() — synchronous; completion gates recreation. Orders out, calls prepareForUITeardown() 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 the ObjectIdentifier-keyed geometry caches. Preserves `videoPlayerWindowController` (mode-independent — closing it stops playback/casts).
  3. Flip isModernUIEnabled — the show*() paths read it to choose classic vs. modern controllers, so it must change between teardown and recreate.
  4. prepareUIRuntime(forModernUI:) — ModernSkinEngine.shared.loadPreferredSkin() entering modern; reset classic spectrum transparent-bg keys entering classic. Classic currentSkin is 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 via loadSkin before reloadUI).
  5. audioEngine.applyEQLayout(forModernUI:) — reprograms the shared fixed-21-band EQ node to the target layout (mirrors to the streaming player internally); guard-idempotent.
  6. rebuildMainMenu() via (NSApp.delegate as? AppDelegate)?.
  7. recreateModeDependentLayout(snapshot) — showMainWindow() + makeKeyAndOrderFront, restore sub-window visibility/frames via show*(at:), re-push presentation state; restore Compact Mode last.

The main window keeps the outgoing *position* and the incoming *size*. Every family lays its main window out at its own base size — classic Skin.mainWindowSize * scale, modern off ModernSkinElements, a .wal skin at whatever its own layout declares — so recreateModeDependentLayout must not stamp the snapshot's whole frame onto the freshly created target-mode window. WindowManager.mainFrameForModeSwitch(outgoing:ownSize:) keeps the snapshot's origin, substitutes the new window's own size, and anchors the top-left (the corner applyDoubleSize resizes around). It runs unconditionally: the UI Size re-apply below happens only when the captured level is not 100%, so relying on it left .wal (Ebonite, 197×297) → Classic drawing a 275×116 skin scaled down inside a 197×297 window at 100% — and the bug correspondingly vanished at any other UI Size, which is what pinned the mechanism. This is the same rule AppStateManager.mainFrameForRestore applies at launch (see the winamp-modern-skin-guide rendering reference, BB2c); a test asserts the two agree.

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.uiMode is 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 only rememberStateEnabled, 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 .accessory transition 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.swift owns a private browser controller (PlexBrowserWindowController or ModernLibraryBrowserWindowController) and calls setCompactMode(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 .accessory until 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 → .regular back-to-back) to force the menu bar to re-own, and calls reassertRegularActivation() (activate + make the main window key + re-apply the Dock icon) on the next runloop turn. See the .accessory → .regular menu-bar gotcha below.
  • Entry points: the Compact Mode item in the main-window right-click menu and the Windows menu (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 and display()s it synchronously before deferring toggleCompactMode() to the next runloop — enterCompactMode is heavy synchronous AppKit work (activation-policy switch, window teardown, status-item creation) that, run inline in mouseUp, would block the press repaint and beachball. The fallback toggle-button renderer keys its highlight off the on-state only (not isPressed), so compactButtonActivating forces 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: startAnchorDiagnosticTimer runs in all builds and NSLogs after ~2.5s with no reveal (and additionally assertionFailures in DEBUG). This is the only release-safe signal for the rare case where button/button.window is nil at show() 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 .moveToActiveSpace for 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, while needsInitialSizing) and never sets the origin. Use display: false for 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 .moveToActiveSpace compact window then follows. The diagnostic signature is a NSWorkspace.didActivateApplicationNotification for another app firing immediately after the policy change. Fix: call NSApp.activate(ignoringOtherApps:) right after setActivationPolicy(.accessory) so NullPlayer stays frontmost on the current Space. The exit path already does this after setActivationPolicy(.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) before orderOutRegularWindows(), so the NSApp.activate above 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, and restoreRegularWindowSnapshot all skip windows where isInNativeFullScreen(_:) (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 enterCompactMode re-activates it under .accessory). In that already-active state both `NSApp.activate` *and* `NSApp.deactivate()` are no-ops — confirmed by diagnostics: after NSApp.deactivate(), NSApp.isActive is still true and 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. exitCompactMode does 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. The restoreRegularWindows: false live-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"). exitCompactMode therefore re-applies restoreDockIconImage() at +0.3s and +0.8s after 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 .app rebuilds from its CFBundleIconFile. Both fixes verified with Dock screenshots (bare binary and a real .app bundle).
  • 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 presentCompactStatusMenu includes a Quit nullPlayer item (NSApplication.terminate(_:)). Without it users are forced to Activity Monitor / force-quit, which bypasses applicationWillTerminate → 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 clean terminate runs saveState and the skin persists — verified.

Live UI switch (Classic↔Modern) while in Compact Mode

reloadUI(toModernUI:) must not naively call exitCompactMode() then enterCompactMode():

  • exitCompactMode restores asynchronously (state stays .exiting until a deferred block), so a synchronous re-enter hits the .regular guard and is silently dropped. exitCompactMode is completion-based; run the teardown/rebuild/re-enter inside the completion.
  • Pass exitCompactMode(restoreRegularWindows: false) on this path: re-showing the still-hidden .managed regular 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-captures regularWindowSnapshot from the live windows, which loses hidden mode-independent app panels (they survive teardown but stay hidden). Carry those fields forward with reapplyModeIndependentWindows(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.

  • isStatusAnchorReady requires both signals: (1) Y — menu-bar proximity (buttonScreenRect.maxY near screen.frame.maxY), and (2) settled X — the icon's rect is not flush against either screen horizontal edge (statusItemEdgeInset). During the .accessory entry churn a brand-new NSStatusItem is 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 .accessory churn (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). After hasRevealed, incidental status-button didMoves 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/NSLog if 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() and deinit.
  • The logic lives entirely in the shared CompactModeWindowController (modernUI only 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.compactWindowEnabled is 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 .normal level 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 while compactWindowEnabled is 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:

  • ModernLibraryBrowserWindowController installs 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 bounds on 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 NSView with a containerLayer whose sublayerTransform applies 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 isCoverFlowMode toggle mirroring isArtOnlyMode (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.didSet calls scheduleCoverFlowRebuild() (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 Default centers main window on its current screen (not always the primary display); measures against visibleFrame and top-anchors a stack too tall to fit. One press recovers everything and a second is a no-op — see Off-Screen Window Recovery
  • Coordinated minimize: uses addChildWindow/removeChildWindow in windowWillMiniaturize/windowDidDeminiaturize to 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:) in WindowManager slides docked windows up when a stack window is hidden. Called from toggleEqualizer/Playlist/Spectrum/Waveform — capture the frame BEFORE orderOut, then call it. Uses BFS over dockThreshold-adjacent windows (by vertical gap + horizontal overlap). Must set isSnappingWindow = true during 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 durationDrag modeBehaviour
< 400 ms (holdThreshold).separateDragged window detaches; peers stay connected to each other
≥ 400 ms.groupAll connected windows move together

Implementation details:

  • DragMode enum: .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 windowWillMove via determineDragMode(holdStart:currentTime:threshold:isWindowLayoutLocked:) (pure static, unit-tested)
  • If isWindowLayoutLocked == true, drag mode is forced to .group regardless 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.willCloseNotification observer cleans up hold state and clears highlights
  • Mid-flight drag (AppKit-initiated, no prior mouseDown): always .group mode (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 a white @ 15% opacity overlay via connectedWindowHighlightDidChange notification. Cleared when drag ends or .separate mode is resolved. All 10 dockable views (5 classic + 5 modern) observe this notification.
  • isMovingDockedWindows flag prevents re-entrant windowWillMove calls while peers are being repositioned

Off-Screen Window Recovery

Sources/NullPlayer/App/WindowPlacement.swift is the single definition of "on screen" for the whole app. Pure statics over NSRect (no NSWindow, no NSScreen lookup), so the rule is unit-testable and every caller provably applies the same one. Do not re-derive it locally.

The rule it encodes: overlapping windows are preferable to hidden ones. A window on top of another is a nuisance the user fixes with one drag; a window with no title bar on screen is ungrabbable and the app is unusable for anyone who does not know Snap To Default exists. Every placement path defers to this ranking.

FunctionContract
isReachable(_:screens:)The window's top-left corner is on some screen. Top edge inclusive (maxY == screen.maxY is where every oversized rescue lands); bottom edge exclusive
hostScreen(for:screens:)Largest intersection → nearest by centre distance → first screen
rescued(_:into:)Moves, never resizes. Larger than the screen on an axis → that axis aligns to the visible top-left
groupOffset(union:into:)One offset for a whole docked cluster, preserving every relative position

Reachability is the top-left corner, not the whole frame. That corner carries the title bar and drag area in all three modes, and the definition deliberately leaves the classic habit of parking a window mostly past the bottom or right edge intact — that window is placed, not stranded, and a sweep that yanked it back would be the bug.

Never resize to recover. A classic sub-window's size is pinned by applyDoubleSize (minSize == maxSize) and a .wal window's size is the skin. WindowManager.recenteredPlayerFrame is the one exception and already shrinks correctly.

Clusters move as a unit. Per-window clamping is what destroys docking: two windows flush against each other, clamped independently against the same edge, come back overlapping instead of touching. Rescue paths compute one groupOffset from the cluster's union, then rescue individually only what that offset could not save (a cluster larger than the display).

The safety net

WindowManager.ensureAllWindowsOnScreen() walks allWindows(), moves each stranded window with its findDockedWindows(to:) cluster, guards with isSnappingWindow = true, ends with postLayoutChangeNotification(), and skips miniaturized windows and full-screen ProjectM. Call sites:

  • end of the +0.1s block in AppStateManager.applySettingsStateAfterReload (after completion?(),

so the .wal arrangement has already run)

  • NSApplication.didChangeScreenParametersNotification, debounced onto the next runloop pass —

macOS posts it repeatedly while a display reconfigures and the frames are not settled until it stops

  • end of applyUIScaleLevelChangeIfNeeded
  • after a .wal skin load in ContextMenuBuilder (three sites)

Per CLAUDE.md this runs in all three modes deliberately — it is not justified as a no-op. An unreachable window is equally unusable in Classic, Original/Modern and Winamp Modern, and the rule is mode-independent. Verify it separately in each.

Restore

AppState.mainScreenVisibleFrame records the screen the main window was on at save time. On restore, AppStateManager.savedScreenIsMissing compares it to the present screens; a mismatch forces correctedRestoredFrames to run unconditionally rather than only on a provable strand, because absolute desktop coordinates mean nothing without the desktop they were measured on. The field is optional with decodeIfPresent, so old states decode unchanged and answer false — unknown is not the same as changed.

correctedRestoredFrames sees the main frame and every sub-frame together (they were decoded up front at the applySettingsStateAfterReload seam for exactly this reason) so one offset can bring the whole docked session back.

Winamp Modern tiler

WinampModernTiler.nextSlot clamps its slot back onto the region on both axes. This reverses the original design, which let columns march right rather than overlap. For a skin wider than half the display (EPS, Big Bento, cPro-Bento) column 2 began past region.maxX, so every window after the first column was placed entirely off screen. tiledOrigin correspondingly never returns nil for want of a free slot — it returns the last slot rescued onto the region, because both call sites read nil as "leave it where it is" and where it is was the problem.

Regression risk: Itemskin (B69) overlays a script-positioned pinned frame window exactly on each component window. Pinned moves bypass the clamp by design, so tiler changes can separate the pair — test Itemskin explicitly.

Related Documentation

  • non-retina-fixes skill - Fixes for rendering artifacts on 1x displays (blue lines, tile seams, text shimmering)

External References

← v1.0.2All versions