Skip to content

refactor: reorganize UI layer and redesign CLI surface #34

Description

@thomaseleff

Summary

Reorganize the audera/server/ package into audera/ui/, extract shared NiceGUI components, and redesign the CLI from a verb-first (run streamer-server) to a subject-first (streamer start) interface. This is a breaking-change refactor with no behavior changes.


Why

The server/ name is a misnomer. The package contains NiceGUI web UIs — not network servers, RPC listeners, or daemons. Callers and contributors have to inspect the code to understand what lives there. The real servers (Snapcast, CamillaDSP, PlexAmp) are external processes; what Audera ships is a UI layer on top of them.

The CLI surface is backwards. audera run streamer-server puts the verb first and buries the subject in a compound noun. Users think in subjects: "I want to do something with the streamer" — not "I want to run something called a streamer-server". The proposed audera streamer start follows the same convention used by docker container start, systemctl start, kubectl pod get, and virtually every modern CLI.

There is no shared UI layer. server/setup.py and server/streamer/app.py both use NiceGUI but define their own header, card layouts, and color choices independently. Any future UI would continue this pattern. Common structure should live in one place.


Architecture

Current layout

audera/
├── server/
│   ├── __init__.py
│   ├── setup.py              # Wi-Fi onboarding wizard (3-page NiceGUI app)
│   └── streamer/
│       ├── __init__.py
│       └── app.py            # Streamer control UI (Players / Services / Settings tabs)
└── cli/
    ├── audera.py             # argparse definition
    └── commands.py           # run(), conf() handlers

Proposed layout

audera/
├── ui/                       # renamed from server/
│   ├── __init__.py
│   ├── components/           # NEW — shared NiceGUI primitives
│   │   ├── __init__.py
│   │   ├── theme.py          # color tokens, spacing, typography helpers
│   │   └── header.py        # shared page header component
│   ├── setup/                # was server/setup.py — promoted to package
│   │   ├── __init__.py       # run(role) entry point (unchanged public API)
│   │   └── pages.py          # welcome(), connect(), finish() page factories
│   └── streamer/
│       ├── __init__.py
│       └── app.py            # unchanged content; updated imports + shared components
└── cli/
    ├── __init__.py
    ├── audera.py             # restructured subparser hierarchy
    └── commands.py           # handlers reorganized by subject

Design choices

1 — ui/ not app/ or web/

app/ conflicts with the common pattern of an app object (Flask, FastAPI, NiceGUI). web/ implies HTTP assets only. ui/ is precise: it holds the user-facing interface layer, whether that interface is served over HTTP or rendered locally.

2 — ui/setup/ as a package, not a module

The setup wizard already has three logical pages (welcome, connect, finish) plus a stateful Page class. Splitting it into __init__.py (entry point) and pages.py (page implementations) separates the public surface from the internals without requiring callers to change.

3 — Shared components extracted, not imposed

ui/components/ is a library of helpers, not a framework. Each UI imports what it needs; nothing is wired automatically. This prevents the components from becoming a hidden dependency on import order or global state.

4 — CLI: {subject} {verb}, not {verb} {noun}

# Before
audera run streamer-server
audera run player-setup
audera conf streamer snapserver.conf
audera conf player camilladsp.yml

# After
audera streamer start
audera streamer conf snapserver.conf
audera player setup
audera player conf camilladsp.yml

Each subject (streamer, player) becomes a subcommand group. Verbs live inside groups. This is consistent with git remote add, docker image pull, and kubectl pod describe.


Implementation details

ui/components/theme.py

PRIMARY   = '#1a1a2e'
SECONDARY = '#16213e'
ACCENT    = '#0f3460'
TEXT      = '#e0e0e0'
MUTED     = '#888888'

def apply_defaults() -> None:
    from nicegui import ui
    ui.colors(primary=PRIMARY, secondary=SECONDARY, accent=ACCENT)

ui/components/header.py

from nicegui import ui

def render(title: str, subtitle: str) -> None:
    with ui.header().classes('items-center justify-between'):
        ui.label('audera').classes('text-h6 text-weight-bold')
        ui.label(subtitle).classes('text-caption text-grey')

ui/setup/__init__.py (public surface unchanged)

from audera.ui.setup.pages import welcome, connect, finish
from audera.ui import components

def run(role: str) -> None:
    components.theme.apply_defaults()
    # ... existing AccessPoint setup, ui.run() call

ui/setup/pages.py (extracted from current setup.py)

# welcome(), connect(), finish() extracted from the Page class methods,
# registered as @ui.page('/'), @ui.page('/connect'), @ui.page('/finish').

cli/audera.py — new subparser structure

import argparse
from audera.cli import commands

def main() -> None:
    parser = argparse.ArgumentParser(prog='audera')
    subs = parser.add_subparsers(dest='subject', required=True)

    # audera streamer ...
    streamer = subs.add_parser('streamer', help='Manage the audera streamer')
    streamer_subs = streamer.add_subparsers(dest='verb', required=True)

    s_start = streamer_subs.add_parser('start', help='Start the streamer web UI')
    s_start.set_defaults(func=commands.streamer_start)

    s_conf = streamer_subs.add_parser('conf', help='Print a bundled streamer config')
    s_conf.add_argument('filename')
    s_conf.set_defaults(func=commands.streamer_conf)

    # audera player ...
    player = subs.add_parser('player', help='Manage an audera player')
    player_subs = player.add_subparsers(dest='verb', required=True)

    p_setup = player_subs.add_parser('setup', help='Run the device Wi-Fi setup wizard')
    p_setup.set_defaults(func=commands.player_setup)

    p_conf = player_subs.add_parser('conf', help='Print a bundled player config')
    p_conf.add_argument('filename')
    p_conf.set_defaults(func=commands.player_conf)

    args = parser.parse_args()
    args.func(**vars(args))

cli/commands.py — renamed handlers (logic unchanged)

# Before: run(type, **_) and conf(type, filename, **_) dispatching on a type string
# After: one function per command; no dispatch table

def streamer_start(**_) -> None:
    from audera import netifaces
    from audera.ui import streamer, setup
    if not netifaces.connected():
        setup.run(role='streamer')
    streamer.run()

def streamer_conf(filename: str, **_) -> None:
    # unchanged body; was commands.conf(type='streamer', filename=filename)
    ...

def player_setup(**_) -> None:
    from audera.ui import setup
    setup.run(role='player')

def player_conf(filename: str, **_) -> None:
    # unchanged body; was commands.conf(type='player', filename=filename)
    ...

Workstreams

WS1 is a prerequisite for WS2 and WS3, which are otherwise independent.


WS1 — Directory restructure and import surgery (~400–500 lines)

Scope: Rename server/ui/, split setup.py into a package, update all internal imports. No logic changes.

Files changed:

  • audera/server/audera/ui/ (move entire tree)
  • audera/server/setup.pyaudera/ui/setup/__init__.py + audera/ui/setup/pages.py
  • audera/server/streamer/app.pyaudera/ui/streamer/app.py (import paths only)
  • audera/__init__.py — update any server re-exports
  • audera/cli/commands.py — update from audera.server.*from audera.ui.*
  • tests/ — update any test imports

Acceptance criteria:

  • uv run ruff check --fix && uv run ruff format && uv run ty check passes with zero errors
  • audera run streamer-server still works (old CLI preserved during transition)
  • audera run player-setup still works

WS2 — Shared UI components (~300–400 lines)

Prerequisite: WS1

Scope: Create ui/components/theme.py and ui/components/header.py. Refactor ui/setup/ and ui/streamer/app.py to use them. Remove all inline color/style duplication.

Files changed:

  • audera/ui/components/__init__.py — new
  • audera/ui/components/theme.py — new; color tokens + apply_defaults()
  • audera/ui/components/header.py — new; render(title, subtitle) function
  • audera/ui/setup/__init__.py — call theme.apply_defaults(); replace inline header with header.render()
  • audera/ui/streamer/app.py — same replacements

Acceptance criteria:

  • Both UIs render the same header markup (verified by inspection)
  • uv run ty check passes
  • No inline hex color literals remain in ui/setup/ or ui/streamer/

WS3 — CLI redesign (~300–400 lines)

Prerequisite: WS1

Scope: Replace the {verb} {noun} argparse layout with {subject} {verb} subparser groups. Remove the old run and conf top-level commands. This is a breaking change — document in CHANGELOG and bump the minor version.

Files changed:

  • audera/cli/audera.py — new subparser structure (see code example above)
  • audera/cli/commands.py — rename run()streamer_start(), player_setup(); rename conf()streamer_conf(), player_conf()
  • README.md — update all CLI usage examples
  • CHANGELOG.md — document breaking change
  • tests/ — update or add CLI dispatch tests

Acceptance criteria:

  • audera streamer start invokes the streamer UI
  • audera player setup invokes the Wi-Fi wizard
  • audera streamer conf snapserver.conf prints the config to stdout
  • Old commands (audera run streamer-server) raise a clear argparse error
  • uv run ty check passes

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions