Two issues over one bar, because they are one relayout. #42's own findings say so: giving wa-slider a label grows it 6px to 14px and moves the transport, which is #23's subject, so doing them in sequence means measuring the bar twice and throwing the first set away. The bar was `320px 1fr auto`, so the transport sat in the middle of what the metadata and the queue button did not use — its centre was ~140px right of the window's at every width. The outer two tracks are the same expression now, so the middle is centred by construction. The side width is the metadata's, capped at a quarter of the bar, and the cap was measured as a regression before it was a decision: reserving the full `--now-playing-width` on both sides is perfectly centred and takes the seek bar's track from 257px to 61px at 800px, and to 0 at 200% text. The control you drag was paying for the symmetry. With the cap it is 246, which is parity. It is a `min()` rather than a breakpoint because that variable is user state — the metadata has a drag handle — and tying both sides to it is also what keeps dragging meaningful; a plain `1fr … 1fr` centres just as well and silently makes the handle a no-op. The volume moved out of `audio-player` into the bar because the transport column has to hold the transport and nothing else, and it joins the queue button in one cell rather than a second column, since the centring compares columns. It is a slider by default and a popup by setting. The stored flag names the *popup*, which is this config's polarity rule — the zero value has to be the intended answer, so an existing config.toml gets the new default with no migration. Inline, the icon is the mute toggle and is named after that action rather than the state, because with the slider beside it there is nothing to disclose; the component tier now covers both presentations rather than whichever is default. Three nested rules in this block began with a bare element selector, which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** — including the ellipsis on the bar's own title and artist, which has therefore never truncated on the device. They are `&`-prefixed now. Filed as #154 for the class and for a check. `bottom-bar.spec.ts` pins both halves separately on purpose: an uncapped build is perfectly centred and fails only the seek-bar width, so a spec asserting centring alone would have passed the regression above. Both were verified by mutation. Closes #23 Closes #42
162 lines
5.8 KiB
Go
162 lines
5.8 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// DefaultDefaultPage is the launch page for a fresh install.
|
|
const DefaultDefaultPage = ViewHome
|
|
|
|
var (
|
|
errUnknownDefaultPage = errors.New("unknown default page")
|
|
errViewCannotLaunch = errors.New("view cannot be the launch page")
|
|
)
|
|
|
|
// QueueFallback identifies what plays, if anything, once the queue
|
|
// runs out with nothing left to auto-advance to.
|
|
type QueueFallback string
|
|
|
|
// Valid QueueFallback values.
|
|
const (
|
|
QueueFallbackStop QueueFallback = "stop"
|
|
QueueFallbackFavorites QueueFallback = "favorites"
|
|
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
|
|
)
|
|
|
|
// DefaultQueueFallback is the fallback behavior for a fresh install.
|
|
const DefaultQueueFallback = QueueFallbackFavorites
|
|
|
|
var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
|
|
|
// GeneralConfig holds general application preferences that don't
|
|
// belong to a more specific subsystem.
|
|
type GeneralConfig struct {
|
|
DefaultPage View `toml:"DefaultPage"`
|
|
QueueFallback QueueFallback `toml:"QueueFallback"`
|
|
// ViewVisibility says which sidebar destinations are shown, keyed by
|
|
// view id.
|
|
//
|
|
// **An absent key means that view's own default** (`Views`), and that
|
|
// is the whole reason this is a map rather than a `HiddenViews
|
|
// []string` or a struct of booleans. A list's zero value is "hide
|
|
// nothing", which cannot express Autotag being off by default without
|
|
// a migration; a struct field for a view that later stops existing is
|
|
// stored garbage somebody has to deprecate. Here a view added later
|
|
// gets its own default rather than being invisible or forcibly
|
|
// visible, an unknown key is dropped on load, and no install needs
|
|
// migrating in either direction. Same polarity rule as
|
|
// AllowMeteredCatalogDownload: the zero value is the intended answer.
|
|
ViewVisibility map[string]bool `toml:"ViewVisibility"`
|
|
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
|
|
// be fetched on a connection the platform calls cellular. It defaults
|
|
// to false, which is the whole point: the zero value is the safe one,
|
|
// so an existing config with no such key refuses by default rather
|
|
// than needing a migration to become careful.
|
|
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
|
|
// PopupVolume draws the bottom bar's volume as a click-to-open popup
|
|
// instead of a slider that is always there (#42).
|
|
//
|
|
// The polarity is the rule this file already states twice: **the
|
|
// zero value is the intended answer**. Inline is the new default, so
|
|
// the flag has to name the *other* choice — an `InlineVolume bool`
|
|
// would default to false and give every existing install the popup
|
|
// this issue exists to stop being the only option, and would need a
|
|
// migration to say otherwise.
|
|
PopupVolume bool `toml:"PopupVolume"`
|
|
}
|
|
|
|
// ApplyDefaults fills zero-value fields with sensible defaults.
|
|
//
|
|
// A launch page naming a *retired* view is treated as a zero value
|
|
// rather than as an error, because the alternative is an app that will
|
|
// not start for anyone who had that page selected when it was removed.
|
|
// An unknown-but-not-retired name still fails Validate: that is a typo,
|
|
// and telling someone about it is the useful answer.
|
|
func (c *GeneralConfig) ApplyDefaults() {
|
|
if _, retired := RetiredViews[c.DefaultPage]; retired {
|
|
c.DefaultPage = ""
|
|
}
|
|
|
|
if c.DefaultPage == "" {
|
|
c.DefaultPage = DefaultDefaultPage
|
|
}
|
|
|
|
if c.QueueFallback == "" {
|
|
c.QueueFallback = DefaultQueueFallback
|
|
}
|
|
}
|
|
|
|
// Validate checks that all values are well-formed.
|
|
func (c *GeneralConfig) Validate() error {
|
|
c.ApplyDefaults()
|
|
|
|
spec, known := LookupView(string(c.DefaultPage))
|
|
if !known {
|
|
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
|
|
}
|
|
|
|
if !spec.CanLaunch {
|
|
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
|
|
}
|
|
|
|
c.normalizeViewVisibility()
|
|
|
|
switch c.QueueFallback {
|
|
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
|
|
// Valid.
|
|
default:
|
|
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// normalizeViewVisibility drops what the stored map may not say, and
|
|
// repairs the one invariant the shell depends on.
|
|
//
|
|
// Three things are dropped or forced, and all three are reachable only
|
|
// from a hand-edited config or from a version that knew different
|
|
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
|
|
// into Settings) says nothing to anybody; a view that is not Hideable
|
|
// cannot be false; and **the launch page is always visible**, because
|
|
// otherwise an install lands on a page with no nav item pointing at it.
|
|
//
|
|
// That last one is a *repair* here and an *error* at the setter
|
|
// (SetViewVisible), deliberately. On load there is nobody to tell and
|
|
// the honest reading of "my launch page is Autotag" is that this user
|
|
// wants Autotag, so it is un-hidden rather than the launch page being
|
|
// silently reset to something they did not choose. At the setter the
|
|
// user is right there and can act, so it refuses and says why.
|
|
func (c *GeneralConfig) normalizeViewVisibility() {
|
|
for id := range c.ViewVisibility {
|
|
spec, known := LookupView(id)
|
|
if !known || !spec.Hideable {
|
|
delete(c.ViewVisibility, id)
|
|
}
|
|
}
|
|
|
|
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
|
|
c.ViewVisibility[string(c.DefaultPage)] = true
|
|
}
|
|
}
|
|
|
|
// ResolvedViewVisibility answers for every known view, so no caller has
|
|
// to know the defaults -- the frontend included, which is why the
|
|
// binding returns this rather than the stored map.
|
|
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
|
|
resolved := make(map[string]bool, len(Views))
|
|
|
|
for _, v := range Views {
|
|
visible := v.VisibleByDefault
|
|
|
|
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
|
|
visible = stored
|
|
}
|
|
|
|
resolved[string(v.ID)] = visible
|
|
}
|
|
|
|
return resolved
|
|
}
|