feat(display): drag-and-drop plugin rotation order for the primary display mode

The primary rotation's order was invisible and unconfigurable: modes are
registered in parallel-load COMPLETION order, so rotation order actually
varied between restarts. Only the niche Vegas Scroll mode had a working
order UI. This adds real, persisted ordering end to end:

Backend:
- config.template.json: new display.plugin_rotation_order (default [],
  fully backward compatible).
- display_controller.py: _apply_plugin_rotation_order() rebuilds
  available_modes grouped by plugin per the configured list (each plugin's
  modes keep their declared order; unlisted plugins follow in existing
  relative order; empty config = exact no-op). Applied at startup after
  parallel load and after live enable/disable reconcile (before the
  existing _resync_mode_index_after_change, which preserves the current
  mode). Mirrors vegas_mode get_ordered_plugins() semantics.
- api_v3.py save_main_config: accepts plugin_rotation_order as a JSON
  array (same parse/guard pattern as vegas_plugin_order).

Frontend:
- New shared widget static/v3/js/widgets/plugin-order-list.js: the Vegas
  section's drag-and-drop list factored out verbatim (native HTML5 drag
  events, saved-order-first rendering, hidden-input JSON sync),
  parameterized by container/order-input/optional exclude-checkbox/badge.
- display.html: Vegas section now calls the shared module; its ~130-line
  inline copy of the same logic is deleted.
- durations.html: new "Rotation Order" card above the durations grid using
  the same module, posting plugin_rotation_order with the existing form.

Deviation from plan, deliberate: durations stay as their own mode-keyed
grid rather than inline in the drag rows - verified display_durations keys
are MODE names (display_controller.py resolves duration per mode_key), not
plugin ids, and one plugin can own several modes, so the planned 1:1
inline pairing was wrong.

Validation: py_compile on both Python files; _apply_plugin_rotation_order
unit-tested standalone (configured order applied, empty-config no-op,
unknown ids skipped - 3/3); both templates render with balanced divs, the
hidden input carries the saved order, and the old inline implementation is
confirmed gone; config.template.json parses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-16 10:40:19 -04:00
co-authored by Claude Sonnet 5
parent 5ebd55d02b
commit d49cb5851d
7 changed files with 290 additions and 172 deletions
+16
View File
@@ -961,6 +961,22 @@ def save_main_config():
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
current_config["sync"]["follower_position"] = pos_val
# Handle primary rotation order (JSON array of plugin ids; same
# parse/validate pattern as vegas_plugin_order above)
if 'plugin_rotation_order' in data:
try:
if isinstance(data['plugin_rotation_order'], str):
parsed = json.loads(data['plugin_rotation_order'])
else:
parsed = data['plugin_rotation_order']
if 'display' not in current_config:
current_config['display'] = {}
current_config['display']['plugin_rotation_order'] = (
list(parsed) if isinstance(parsed, (list, tuple)) else []
)
except (json.JSONDecodeError, TypeError, ValueError):
logger.warning("Malformed plugin_rotation_order ignored")
# Handle display durations
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
if duration_fields: