Skip to content

Ask each Advanced setting once, instead of spreading it over switches - #41

Merged
revtex merged 2 commits into
mainfrom
clarify-ad-settings
Aug 29, 2026
Merged

Ask each Advanced setting once, instead of spreading it over switches#41
revtex merged 2 commits into
mainfrom
clarify-ad-settings

Conversation

@revtex

@revtex revtex commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Two settings on the Advanced page were spread across several controls that could not all be true at once, so the page spent its height greying things out to explain hierarchies that were never visible in the first place. Both collapse to a dropdown, by the same arithmetic each time.

Advertisements: three switches, four combinations, three outcomes

Mute advertisements / Record everything Spotify plays / Include advertisements. Two of the three did nothing unless a third allowed it. It is now recording.recordSelection:

  • Recognisable tracks only (default)
  • Everything except advertisements
  • Everything, advertisements included

Mute advertisements never muted anything, which is why this was worth looking at rather than relabelling. AudioSessions.SetMute had no callers, so the switch shipped on by default and its one real effect was to veto record-everything silently — turn that on and nothing happened, with nothing on the page to say why. The predecessor never hit this because its form unchecked mute-ads the moment record-everything was checked; Offstream had inherited the rule and not the interlock.

Restoring muting was considered and rejected. The predecessor's version muted every other application on the machine and held Spotify at full volume, to catch video adverts on a separate audio session — so it never silenced the advert either. A recorder reaching out to mute unrelated applications is a side effect nobody asked for. It is now off the roadmap rather than owed.

Existing files: three values and a boolean, six combinations, four outcomes

"Also tell Spotify to move on" did nothing under Replace it or Save the new one alongside it — both write the file again and leave nothing to move past. It was the second half of one answer, and is now the second of four:

  • Keep the one on disk
  • Keep it, and skip to the next track
  • Replace it
  • Save the new one alongside it

The dangerous edit

Adding a member to ExistingFilePolicy is the one change here that could have broken silently. Four sites compared against ExistingFilePolicy.Skip, and one — the check in TrackRecorder.AlreadyOnDisk — compared negated. A member missing from a comparison like that fails no build and no test; it quietly records over a file the user asked to keep. All four now ask RecordingSettings.KeepsTheExistingFile, and the enriched-template test became a Theory over both keep-it policies to pin it.

Skip keeps its name and its zero value: member names are what settings.json stores, JsonStringEnumConverter throws on a value it does not recognise, and SettingsStore.Load turns that into a hard "fix or delete the file to start from defaults". Renaming it to Keep would fail every existing install to gain a tidier identifier.

A test removed rather than converted

Session_UnderAnOverwritePolicy_NeverAsksSpotifyToMoveOn existed to prove the core read the policy and the boolean together, because a hand-edited settings file could disagree with the greyed-out page. That state cannot be expressed now. Its replacement asserts the live question instead: under Overwrite the track is recorded, not kept — the negative side of KeepsTheExistingFile.

Settings keys

recording.recordSelection replaces three keys and output.existingFilePolicy absorbs one. There is no importer, so a settings.json tuned around the old behaviour comes back at the defaults for those settings. Deliberate — the keys never shipped in a release, and a permanent migration path is a poor trade for them.

The rest of the page

  • Descriptions are tooltips. A line of prose under every switch cost height on a page that has none — the bottom card has been clipped three times — and said the same thing whether or not anyone needed it. The row carries a ToolTip; the control carries the same sentence as AutomationProperties.HelpText, because a tooltip is the one thing on the page a screen reader cannot see. Text unchanged, still translated. Recorded as a standing convention in CLAUDE.md rather than a one-off.
  • Two settings moved out of File names. What to do about a file already on disk is a rule about recording, not about what recordings are called. They sit in Recording, which is what the Detection card is now called — it had long since stopped being only about detection.
  • Giving the policy dropdown its own row also fixes the switch label, which was clipped at "Also tell Spotify to mov".

The height test could not be kept

A build-failing measurement test was attempted. It measures correctly — 495 units for Advanced against the Settings page's 536, at the shell's content width of 976 — but StaticResource resolves at load time, so the dictionaries must exist before the page's constructor runs, which needs an Application. Application.Current is a process-wide singleton bound to its creating thread, and that thread ends with the test, leaving every later test in the assembly marshalling onto a dead dispatcher: nine RecordViewModelTests failed on the first run. Measuring the shell instead does not work either, since an unshown window arranges to nothing.

Settings has never been reported as clipped, so Advanced now fits wherever Settings does — the closest thing to a proof available without a shown window. Both the measurement and the two dead ends are written up in docs/MODERNIZATION-PLAN.md, with the assembly-scoped STA fixture that would actually work.

Filename presets, said out loud

Two unlabelled buttons under the template box read as actions on what is in it — reset, apply — rather than as layouts to start from. They now sit behind a Presets caption, and there are five:

Preset Layout
Artist and title {artist} - {title}
A folder per album {artist}\{album} ({year})\{title}
Numbered tracks {artist}\{album} ({year})\{track:00} {title}
Albums by year {artist}\({year}) {album}\{track:00} {title}
A folder per day {date:yyyy-MM-dd}\{artist} - {title}

Each button's tooltip is the path its layout produces, rendered by OutputPaths.BuildFromTemplate — the same call behind the live preview — so an example cannot promise a name the recorder would not write, and the 260-character budgeting is included rather than omitted by a hand-rolled preview. The output root is dropped from a preset's example and kept in the live preview: the preview answers where the next recording goes, a preset answers what shape the names are, and the root is identical across all five. It is still passed to the renderer either way, so the length budgeting sees the real path.

The presets are data now, not a command and a button apiece. A new layout costs a row in one table rather than a command, a resource key, a button and a tooltip — and every button goes through one UsePreset command with its own preset as the parameter. That makes a wrongly-bound parameter the failure mode worth testing, so Presets_AllOfferATemplateTheRendererAccepts and UsePreset_WritesThatPresetIntoTheTemplate cover it. A preset is a string literal in a table, and nothing else stops a typo'd token from shipping: it costs a click to discover, and the click overwrites the template the user was happy with.

The tooltip here is the outcome, not a description — a departure from the convention this PR writes into CLAUDE.md. What a screen reader needs on these buttons is not an account of the control but the example itself, so HelpText carries the same sentence and the label goes in AutomationProperties.Name.

On height: the row already existed, so at the window's usual width nothing changes. The WrapPanel is there so the fifth button moves down at the narrowest width instead of being clipped silently — about 36 units, putting the page near 531 against the Settings page's 536. Still under the comparison the fit rests on, but the margin is now that difference rather than forty units, and the plan's measurement note says so.

Testing

924 Offstream.Core.Tests + 204 Offstream.UI.Tests green, 0 warnings under analyzers-as-errors. The page itself was reviewed by hand — the layout claim is a measurement plus a comparison, not a test.

🤖 Generated with Claude Code

revtex and others added 2 commits August 29, 2026 10:00
Two settings on that page were spread across several controls that could
not all be true at once, so the page spent its height greying things out
to explain hierarchies that were never visible in the first place. Both
collapse to a dropdown, by the same arithmetic each time: count the
combinations the controls allowed, count the outcomes they produced, and
the difference is what the user had to keep rediscovering.

Mute advertisements / Record everything Spotify plays / Include
advertisements: three switches, four combinations, three outcomes. Two
of the switches did nothing unless a third allowed it. It is now
recording.recordSelection, with the values the booleans could actually
express between them - recognisable tracks only, everything except
advertisements, everything including them.

Mute advertisements never muted anything, which is why this was worth
looking at rather than relabelling. AudioSessions.SetMute had no callers,
so the switch shipped on by default and its one real effect was to veto
record-everything silently: turn that on and nothing happened, with
nothing on the page to say why. The predecessor never hit this because
its form unchecked mute-ads the moment record-everything was checked;
Offstream had inherited the rule and not the interlock. Restoring it was
considered and rejected: the predecessor's mute-ads muted every other
application on the machine and held Spotify at full volume, to catch
video adverts on a separate audio session, so it never silenced the
advert either. A recorder reaching out to mute unrelated applications is
a side effect nobody asked for.

Existing-file policy plus "also tell Spotify to move on": three values
and a boolean, six combinations, four outcomes. Moving on does nothing
under Replace it or Save the new one alongside it, since both write the
file again and leave nothing to move past. The switch was the second
half of one answer, and it is now the second of four:
ExistingFilePolicy.SkipAndMoveOn.

Adding a member to that enum is the one dangerous edit here. Four sites
compared against ExistingFilePolicy.Skip, and one of them - the check in
TrackRecorder.AlreadyOnDisk - compared negated. A member missing from a
comparison like that fails no build and no test; it quietly records over
a file the user asked to keep. All four now ask
RecordingSettings.KeepsTheExistingFile, and the enriched-template test
became a Theory over both keep-it policies to pin it.

Skip keeps its name and its zero value. Member names are what
settings.json stores and JsonStringEnumConverter throws on a value it
does not recognise, which SettingsStore.Load turns into a hard "fix or
delete the file to start from defaults" - so renaming it to Keep would
fail every existing install to gain a tidier identifier.

Session_UnderAnOverwritePolicy_NeverAsksSpotifyToMoveOn is gone rather
than converted. It existed to prove the core read the policy and the
boolean together, because a hand-edited settings file could disagree
with the greyed-out page. That state cannot be expressed now. What
replaced it asserts the live question instead: under Overwrite the track
is recorded, not kept - the negative side of KeepsTheExistingFile.

Both collapses drop settings keys. recording.recordSelection replaces
three, output.existingFilePolicy absorbs one, and there is no importer,
so a settings.json tuned around the old behaviour comes back at the
defaults for those settings. Deliberate: the keys never shipped in a
release, and a permanent migration path is a poor trade for them.

The rest of the page follows from having room again:

Descriptions are tooltips. A line of prose under every switch cost
height on a page that has none - the bottom card has now been clipped
three times - and said the same thing whether or not anyone needed it.
The row carries a ToolTip so hovering anywhere finds it, and the control
carries the same sentence as AutomationProperties.HelpText, because a
tooltip is the one thing on the page a screen reader cannot see. Text
unchanged, still in Strings.resx and .fr.resx, still translated.
Recorded as a standing convention in CLAUDE.md rather than a one-off.

The existing-file policy and the move-on setting moved out of File
names. What to do about a file already on disk is a rule about
recording, not about what recordings are called. They now sit in
Recording, which is what the Detection card is called: it had long since
stopped being only about detection.

Giving the policy dropdown its own row also fixes the switch label,
which was clipped at "Also tell Spotify to mov".

A test that fails the build on the page's height was attempted and
cannot be kept. It measures correctly - 495 units for Advanced against
the Settings page's 536, at the shell's content width of 976 - but
StaticResource resolves at load time, so the dictionaries must exist
before the page's constructor runs, which needs an Application.
Application.Current is a process-wide singleton bound to its creating
thread, and that thread ends with the test, leaving every later test in
the assembly marshalling onto a dead dispatcher: nine RecordViewModelTests
failed on the first run. Measuring the shell instead does not work
either, since an unshown window arranges to nothing. Settings has never
been reported as clipped, so Advanced now fits wherever Settings does -
which is the closest thing to a proof available without a shown window.
Both the measurement and the two dead ends are in the plan, with the
assembly-scoped STA fixture that would actually work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unlabelled buttons under the template box read as actions on what is
in it - reset, apply - rather than as layouts to start from. They now sit
behind a Presets caption, docked left rather than on a line of its own,
because this card is at the top of a page that has to fit the window's
minimum height and a row spent on one word is a row the bottom card
loses.

Three more layouts, chosen for how people actually keep music rather than
for covering the token grammar. Numbered tracks puts the track number in
front of each title, so players and Explorer keep album order. Albums by
year leads the album folder with the year, so an artist's records sort
chronologically instead of alphabetically - the layout a user was already
typing by hand. A folder per day groups by recording date rather than by
album, for long sessions and radio.

Each button's tooltip is the path its layout produces, rendered by
OutputPaths.BuildFromTemplate - the same call behind the live preview, so
an example cannot promise a name the recorder would not write, and the
260-character budgeting is included rather than omitted by a hand-rolled
preview. A preset is only ever picked for its outcome, and the label has
room for a name but not for a path. The output root is dropped from a
preset's example and kept in the live preview: the preview answers where
the next recording goes, a preset answers what shape the names are, and
the root is identical across all five. It is still passed to the renderer
either way, so the length budgeting sees the real path.

The presets are data now, not a command and a button apiece. A new layout
costs a row in one table rather than a command, a resource key, a button
and a tooltip, and every button goes through one UsePreset command with
its own preset as the parameter. That makes a wrongly-bound parameter the
failure mode worth a test, so there is one: every button applies its own
template, every preset's template passes FileNameTemplate.Validate, and
every example renders. A preset is a string literal in a table, and
nothing else stops a typo'd token from shipping - it costs a click to
discover, and the click overwrites the template the user was happy with.

Presets is a get-only property rebuilt per read, notified alongside
TemplatePreview when the document changes, because each example ends in
the output format's extension and the Settings page can change that
underneath this one.

The tooltip is the example rather than a description, which is a
departure from the convention CLAUDE.md now carries: what a screen reader
needs on these buttons is not an account of the control but the outcome
itself. So HelpText is the same sentence, and the label goes in
AutomationProperties.Name.

Height, which is the constraint this page keeps losing to: the row
already existed, so at the window's usual width nothing changes. The
WrapPanel is there so that at the narrowest width the fifth button moves
down instead of being clipped silently, which costs about 36 units and
puts the page near 531 against the Settings page's 536. Still under the
comparison the fit rests on, but the margin is now that difference rather
than forty units, and the plan's measurement note says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@revtex
revtex merged commit 750da6d into main Aug 29, 2026
6 checks passed
@revtex
revtex deleted the clarify-ad-settings branch August 29, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant