@mthines/reaper-mcp 0.16.0 → 0.17.0-beta.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ npx @mthines/reaper-mcp init --yes
22
22
  The `init` wizard walks you through:
23
23
  1. Installing the REAPER bridge (Lua + JSFX analyzers)
24
24
  2. Installing AI mix knowledge and agents
25
- 3. Configuring Claude Code settings (auto-allows all 78 REAPER tools)
25
+ 3. Configuring Claude Code settings (auto-allows all 80 REAPER tools)
26
26
  4. Optionally creating a project-local `.mcp.json`
27
27
 
28
28
  Then load `mcp_bridge.lua` in REAPER and open Claude Code — you're ready to mix.
@@ -31,7 +31,7 @@ Then load `mcp_bridge.lua` in REAPER and open Claude Code — you're ready to mi
31
31
 
32
32
  ```
33
33
  Claude Code
34
- ├── MCP Tools (67) ──→ controls REAPER in real-time
34
+ ├── MCP Tools (80) ──→ controls REAPER in real-time
35
35
  │ ├── Track management (list, get/set properties, arm, phase, input)
36
36
  │ ├── FX management (add/remove, get/set parameters, enable/offline, presets)
37
37
  │ ├── Transport (play, stop, record, cursor position)
@@ -43,7 +43,7 @@ Claude Code
43
43
  │ ├── MIDI editing (14 tools: notes, CC, items, analysis, batch ops)
44
44
  │ ├── Media items (11 tools: properties, split, move, trim, stretch)
45
45
  │ ├── Plugin discovery (list installed FX, search, presets)
46
- │ ├── Snapshots (save/restore mixer state for A/B comparison)
46
+ │ ├── Snapshots (save/restore/delete mixer state for A/B comparison)
47
47
  │ └── Routing (sends, receives, bus structure)
48
48
 
49
49
  └── Mix Engineer Knowledge ──→ knows HOW to use those tools
@@ -81,7 +81,7 @@ npx @mthines/reaper-mcp init
81
81
  The wizard guides you through selecting which components to install:
82
82
  - **REAPER Bridge** — Lua bridge + JSFX analyzers (copied to your REAPER resource folder)
83
83
  - **AI Skills & Agents** — knowledge base, mix agents, rules, skills (global or project-local)
84
- - **Claude Code Settings** — auto-allows all 78 REAPER tools (no permission prompts)
84
+ - **Claude Code Settings** — auto-allows all 80 REAPER tools (no permission prompts)
85
85
  - **Project Config** — `.mcp.json` for the current directory (opt-in)
86
86
 
87
87
  For CI/automation, use `--yes` to skip prompts and install everything with defaults:
@@ -108,6 +108,7 @@ npx @mthines/reaper-mcp install-skills --project # project-local alternative
108
108
 
109
109
  The `setup` command copies into your REAPER resource folder:
110
110
  - `mcp_bridge.lua` — persistent Lua bridge script
111
+ - `mcp_snapshot_manager.lua` — snapshot manager GUI (save/restore/delete from REAPER)
111
112
  - `mcp_analyzer.jsfx` — FFT spectrum analyzer
112
113
  - `mcp_lufs_meter.jsfx` — ITU-R BS.1770 LUFS meter
113
114
  - `mcp_correlation_meter.jsfx` — stereo correlation analyzer
@@ -138,7 +139,7 @@ npx @mthines/reaper-mcp doctor
138
139
 
139
140
  Checks that the bridge is connected, knowledge is installed, and MCP config exists.
140
141
 
141
- ## MCP Tools (67)
142
+ ## MCP Tools (80)
142
143
 
143
144
  ### Project & Tracks
144
145
 
@@ -250,6 +251,7 @@ Checks that the bridge is connected, knowledge is installed, and MCP config exis
250
251
  | `snapshot_save` | Save current mixer state (volumes, pans, FX, mutes) |
251
252
  | `snapshot_restore` | Restore a saved snapshot |
252
253
  | `snapshot_list` | List all saved snapshots |
254
+ | `snapshot_delete` | Delete a saved snapshot by name |
253
255
 
254
256
  ### Routing
255
257
 
@@ -373,7 +375,7 @@ Processing decisions adapt to the genre:
373
375
 
374
376
  ## Autonomous Mode (Allow All Tools)
375
377
 
376
- By default Claude Code asks permission for each MCP tool call. The `init` command (and `install-skills`) automatically configures `settings.json` to allow all 78 REAPER tools. If you need to set this up manually, add to your project's `.claude/settings.json` (or `~/.claude/settings.json` for global):
378
+ By default Claude Code asks permission for each MCP tool call. The `init` command (and `install-skills`) automatically configures `settings.json` to allow all 80 REAPER tools. If you need to set this up manually, add to your project's `.claude/settings.json` (or `~/.claude/settings.json` for global):
377
379
 
378
380
  ```json
379
381
  {
@@ -404,6 +406,7 @@ By default Claude Code asks permission for each MCP tool call. The `init` comman
404
406
  "mcp__reaper__snapshot_save",
405
407
  "mcp__reaper__snapshot_restore",
406
408
  "mcp__reaper__snapshot_list",
409
+ "mcp__reaper__snapshot_delete",
407
410
  "mcp__reaper__get_track_routing",
408
411
  "mcp__reaper__set_fx_enabled",
409
412
  "mcp__reaper__set_fx_offline",
@@ -525,7 +528,7 @@ Platform defaults:
525
528
  ```
526
529
  reaper-mcp/
527
530
  ├── apps/
528
- │ ├── reaper-mcp-server/ # MCP server (67 tools, esbuild bundle)
531
+ │ ├── reaper-mcp-server/ # MCP server (80 tools, esbuild bundle)
529
532
  │ └── reaper-mix-agent/ # AI mix agent (knowledge loader, plugin resolver)
530
533
  ├── libs/protocol/ # Shared TypeScript types
531
534
  ├── knowledge/ # AI mix engineer knowledge base
@@ -535,6 +538,7 @@ reaper-mcp/
535
538
  │ └── reference/ # Frequency, compression, metering, perceived loudness cheat sheets
536
539
  ├── reaper/ # Files installed into REAPER
537
540
  │ ├── mcp_bridge.lua # Persistent Lua bridge
541
+ │ ├── mcp_snapshot_manager.lua # Snapshot manager GUI (save/restore/delete)
538
542
  │ ├── mcp_analyzer.jsfx # FFT spectrum analyzer
539
543
  │ ├── mcp_lufs_meter.jsfx # LUFS meter (BS.1770)
540
544
  │ ├── mcp_correlation_meter.jsfx # Stereo correlation meter
@@ -28,7 +28,7 @@ You are a professional mix engineer with 20 years of experience working inside R
28
28
 
29
29
  ## Available MCP Tools
30
30
 
31
- You have access to 67 REAPER tools via the `reaper` MCP server:
31
+ You have access to 80 REAPER tools via the `reaper` MCP server:
32
32
 
33
33
  ### Session Info
34
34
  - `get_project_info` — project name, tempo, time sig, sample rate, transport
@@ -70,6 +70,7 @@ You have access to 67 REAPER tools via the `reaper` MCP server:
70
70
  - `snapshot_save` — save mixer state
71
71
  - `snapshot_restore` — restore saved state
72
72
  - `snapshot_list` — list all snapshots
73
+ - `snapshot_delete` — delete a saved snapshot
73
74
 
74
75
  ### Markers & Regions
75
76
  - `list_markers` — all project markers (index, name, position, color)
package/main.js CHANGED
@@ -907,6 +907,20 @@ function registerSnapshotTools(server) {
907
907
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
908
908
  }
909
909
  );
910
+ server.tool(
911
+ "snapshot_delete",
912
+ "Delete a named mixer snapshot. Removes the snapshot file from .reaper-mcp/snapshots/ alongside the project file (or global bridge dir for unsaved projects). This action is permanent and cannot be undone.",
913
+ {
914
+ name: z7.string().min(1).describe("Name of the snapshot to delete")
915
+ },
916
+ async ({ name }) => {
917
+ const res = await sendCommand("snapshot_delete", { name });
918
+ if (!res.success) {
919
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
920
+ }
921
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
922
+ }
923
+ );
910
924
  }
911
925
 
912
926
  // apps/reaper-mcp-server/src/tools/routing.ts
@@ -2292,7 +2306,8 @@ var REAPER_ASSETS = [
2292
2306
  "mcp_analyzer.jsfx",
2293
2307
  "mcp_lufs_meter.jsfx",
2294
2308
  "mcp_correlation_meter.jsfx",
2295
- "mcp_crest_factor.jsfx"
2309
+ "mcp_crest_factor.jsfx",
2310
+ "mcp_snapshot_manager.lua"
2296
2311
  ];
2297
2312
  var MCP_TOOL_NAMES = [
2298
2313
  // project
@@ -2330,6 +2345,7 @@ var MCP_TOOL_NAMES = [
2330
2345
  "snapshot_save",
2331
2346
  "snapshot_restore",
2332
2347
  "snapshot_list",
2348
+ "snapshot_delete",
2333
2349
  // routing
2334
2350
  "get_track_routing",
2335
2351
  // midi
@@ -2479,21 +2495,23 @@ async function runInit(opts, dirResolver) {
2479
2495
  const scriptsDir = getReaperScriptsPath();
2480
2496
  mkdirSync2(scriptsDir, { recursive: true });
2481
2497
  const reaperDir = resolveAssetDir(__dirname2, "reaper");
2482
- const luaSrc = join4(reaperDir, "mcp_bridge.lua");
2483
- const luaDest = join4(scriptsDir, "mcp_bridge.lua");
2484
- if (installFile(luaSrc, luaDest)) {
2485
- console.log(" Installed: mcp_bridge.lua");
2486
- } else {
2487
- console.log(` Not found: ${luaSrc}`);
2498
+ for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2499
+ const src = join4(reaperDir, luaFile);
2500
+ const dest = join4(scriptsDir, luaFile);
2501
+ if (installFile(src, dest)) {
2502
+ console.log(` Installed: ${luaFile}`);
2503
+ } else {
2504
+ console.log(` Not found: ${src}`);
2505
+ }
2488
2506
  }
2489
2507
  const effectsDir = join4(getReaperEffectsPath(), "reaper-mcp");
2490
2508
  mkdirSync2(effectsDir, { recursive: true });
2491
- for (const jsfx of REAPER_ASSETS) {
2492
- if (jsfx === "mcp_bridge.lua") continue;
2493
- const src = join4(reaperDir, jsfx);
2494
- const dest = join4(effectsDir, jsfx);
2509
+ for (const asset of REAPER_ASSETS) {
2510
+ if (asset.endsWith(".lua")) continue;
2511
+ const src = join4(reaperDir, asset);
2512
+ const dest = join4(effectsDir, asset);
2495
2513
  if (installFile(src, dest)) {
2496
- console.log(` Installed: reaper-mcp/${jsfx}`);
2514
+ console.log(` Installed: reaper-mcp/${asset}`);
2497
2515
  } else {
2498
2516
  console.log(` Not found: ${src}`);
2499
2517
  }
@@ -2596,23 +2614,25 @@ async function setup() {
2596
2614
  const scriptsDir = getReaperScriptsPath();
2597
2615
  mkdirSync3(scriptsDir, { recursive: true });
2598
2616
  const reaperDir = resolveAssetDir(__dirname, "reaper");
2599
- const luaSrc = join5(reaperDir, "mcp_bridge.lua");
2600
- const luaDest = join5(scriptsDir, "mcp_bridge.lua");
2601
- console.log("Installing Lua bridge...");
2602
- if (installFile(luaSrc, luaDest)) {
2603
- console.log(` Installed: mcp_bridge.lua`);
2604
- } else {
2605
- console.log(` Not found: ${luaSrc}`);
2617
+ console.log("Installing Lua scripts...");
2618
+ for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
2619
+ const src = join5(reaperDir, luaFile);
2620
+ const dest = join5(scriptsDir, luaFile);
2621
+ if (installFile(src, dest)) {
2622
+ console.log(` Installed: ${luaFile}`);
2623
+ } else {
2624
+ console.log(` Not found: ${src}`);
2625
+ }
2606
2626
  }
2607
2627
  const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
2608
2628
  mkdirSync3(effectsDir, { recursive: true });
2609
2629
  console.log("\nInstalling JSFX analyzers...");
2610
- for (const jsfx of REAPER_ASSETS) {
2611
- if (jsfx === "mcp_bridge.lua") continue;
2612
- const src = join5(reaperDir, jsfx);
2613
- const dest = join5(effectsDir, jsfx);
2630
+ for (const asset of REAPER_ASSETS) {
2631
+ if (asset.endsWith(".lua")) continue;
2632
+ const src = join5(reaperDir, asset);
2633
+ const dest = join5(effectsDir, asset);
2614
2634
  if (installFile(src, dest)) {
2615
- console.log(` Installed: reaper-mcp/${jsfx}`);
2635
+ console.log(` Installed: reaper-mcp/${asset}`);
2616
2636
  } else {
2617
2637
  console.log(` Not found: ${src}`);
2618
2638
  }
@@ -2621,7 +2641,7 @@ async function setup() {
2621
2641
  console.log("Next steps:");
2622
2642
  console.log(" 1. Open REAPER");
2623
2643
  console.log(" 2. Actions > Show action list > Load ReaScript");
2624
- console.log(` 3. Select: ${luaDest}`);
2644
+ console.log(` 3. Select: ${join5(scriptsDir, "mcp_bridge.lua")}`);
2625
2645
  console.log(" 4. Run the script (it will keep running in background via defer loop)");
2626
2646
  console.log(" 5. Add reaper-mcp to your Claude Code config (see: npx @mthines/reaper-mcp doctor)");
2627
2647
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-beta.18.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
package/reaper/CLAUDE.md CHANGED
@@ -7,6 +7,7 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
7
7
  | File | Language | Purpose |
8
8
  |------|----------|---------|
9
9
  | `mcp_bridge.lua` | Lua | Persistent bridge: polls for JSON commands, executes ReaScript API, writes responses |
10
+ | `mcp_snapshot_manager.lua` | Lua | Standalone snapshot manager GUI (gfx-based, save/restore/delete snapshots) |
10
11
  | `mcp_analyzer.jsfx` | JSFX/EEL2 | Real-time FFT spectrum analyzer, writes to gmem[] |
11
12
  | `mcp_lufs_meter.jsfx` | JSFX/EEL2 | LUFS loudness metering |
12
13
  | `mcp_correlation_meter.jsfx` | JSFX/EEL2 | Stereo correlation and width analysis |
package/reaper/install.sh CHANGED
@@ -23,11 +23,13 @@ fi
23
23
 
24
24
  echo "REAPER resource path: $REAPER_PATH"
25
25
 
26
- # Install Lua bridge
26
+ # Install Lua scripts
27
27
  SCRIPTS_DIR="$REAPER_PATH/Scripts"
28
28
  mkdir -p "$SCRIPTS_DIR"
29
29
  cp "$SCRIPT_DIR/mcp_bridge.lua" "$SCRIPTS_DIR/mcp_bridge.lua"
30
30
  echo "Installed: $SCRIPTS_DIR/mcp_bridge.lua"
31
+ cp "$SCRIPT_DIR/mcp_snapshot_manager.lua" "$SCRIPTS_DIR/mcp_snapshot_manager.lua"
32
+ echo "Installed: $SCRIPTS_DIR/mcp_snapshot_manager.lua"
31
33
 
32
34
  # Install JSFX analyzers
33
35
  EFFECTS_DIR="$REAPER_PATH/Effects/reaper-mcp"
@@ -1426,6 +1426,26 @@ function handlers.snapshot_list(params)
1426
1426
  return { snapshots = snapshots, total = #snapshots, storageLocation = get_snapshot_storage_location() }
1427
1427
  end
1428
1428
 
1429
+ function handlers.snapshot_delete(params)
1430
+ local name = params.name
1431
+ if not name or name == "" then
1432
+ return nil, "name required"
1433
+ end
1434
+
1435
+ local path = snapshot_path(name)
1436
+ local content = read_file(path)
1437
+ if not content then
1438
+ return nil, "Snapshot not found: " .. name
1439
+ end
1440
+
1441
+ local ok, err = os.remove(path)
1442
+ if not ok then
1443
+ return nil, "Failed to delete snapshot: " .. (err or "unknown error")
1444
+ end
1445
+
1446
+ return { name = name, deleted = true }
1447
+ end
1448
+
1429
1449
  -- =============================================================================
1430
1450
  -- Routing handler
1431
1451
  -- =============================================================================
@@ -0,0 +1,997 @@
1
+ -- =============================================================================
2
+ -- MCP Snapshot Manager for REAPER
3
+ -- =============================================================================
4
+ -- A standalone gfx.* UI for managing mixer snapshots created by the MCP server.
5
+ -- Uses only built-in REAPER APIs (no ReaImGui, no SWS required).
6
+ --
7
+ -- Install: Actions > Show action list > Load ReaScript > select this file > Run
8
+ -- =============================================================================
9
+
10
+ local TITLE = "MCP Snapshot Manager"
11
+ local WIN_W = 820
12
+ local WIN_H = 520
13
+ local PADDING = 10
14
+ local ROW_H = 26
15
+ local HEADER_H = 34
16
+ local BUTTON_H = 32
17
+ local BUTTON_W = 120
18
+ local FOOTER_H = BUTTON_H + PADDING * 2
19
+ local STATUS_H = 22
20
+ local LIST_TOP = HEADER_H + PADDING
21
+ local LIST_BOTTOM_MARGIN = FOOTER_H + STATUS_H + PADDING
22
+ local SCROLL_BAR_W = 14
23
+
24
+ -- Column widths
25
+ local COL_NAME_W = 220
26
+ local COL_DESC_W = 280
27
+ local COL_DATE_W = 160
28
+ local COL_TRACKS_W = 60
29
+
30
+ -- Colors (R, G, B, A as 0-1 floats)
31
+ local C_BG = { 0.13, 0.13, 0.15, 1.0 }
32
+ local C_HEADER_BG = { 0.18, 0.18, 0.22, 1.0 }
33
+ local C_ROW_EVEN = { 0.15, 0.15, 0.18, 1.0 }
34
+ local C_ROW_ODD = { 0.17, 0.17, 0.20, 1.0 }
35
+ local C_ROW_SEL = { 0.20, 0.38, 0.60, 1.0 }
36
+ local C_ROW_HOVER = { 0.22, 0.22, 0.28, 1.0 }
37
+ local C_TEXT = { 0.90, 0.90, 0.90, 1.0 }
38
+ local C_TEXT_DIM = { 0.55, 0.55, 0.60, 1.0 }
39
+ local C_TEXT_SEL = { 1.00, 1.00, 1.00, 1.0 }
40
+ local C_TEXT_HEADER = { 0.75, 0.75, 0.80, 1.0 }
41
+ local C_BTN_NORMAL = { 0.25, 0.25, 0.30, 1.0 }
42
+ local C_BTN_HOVER = { 0.30, 0.45, 0.65, 1.0 }
43
+ local C_BTN_PRESS = { 0.20, 0.35, 0.55, 1.0 }
44
+ local C_BTN_DELETE = { 0.55, 0.20, 0.20, 1.0 }
45
+ local C_BTN_DEL_HOV = { 0.70, 0.25, 0.25, 1.0 }
46
+ local C_BTN_RESTORE = { 0.20, 0.50, 0.25, 1.0 }
47
+ local C_BTN_RES_HOV = { 0.25, 0.62, 0.30, 1.0 }
48
+ local C_DIVIDER = { 0.30, 0.30, 0.35, 1.0 }
49
+ local C_SCROLLBAR = { 0.35, 0.35, 0.40, 1.0 }
50
+ local C_SCROLLTHUMB = { 0.50, 0.50, 0.58, 1.0 }
51
+ local C_STATUS_OK = { 0.35, 0.75, 0.40, 1.0 }
52
+ local C_STATUS_ERR = { 0.85, 0.35, 0.35, 1.0 }
53
+ local C_STATUS_INFO = { 0.65, 0.65, 0.70, 1.0 }
54
+
55
+ -- =============================================================================
56
+ -- State
57
+ -- =============================================================================
58
+
59
+ local snapshots = {} -- list of snapshot tables
60
+ local selected_idx = 0 -- 1-based, 0 = none
61
+ local scroll_offset = 0 -- rows scrolled from top
62
+ local hover_row = 0 -- 1-based row under mouse, 0 = none
63
+ local hover_btn = nil -- "save" | "restore" | "delete" | "refresh" | "close"
64
+ local btn_pressed = nil
65
+ local status_msg = ""
66
+ local status_type = "info" -- "ok" | "err" | "info"
67
+ local status_timer = 0
68
+ local last_mouse_x = 0
69
+ local last_mouse_y = 0
70
+ local last_mouse_cap = 0
71
+ local mouse_was_down = false
72
+ local is_running = true
73
+
74
+ -- =============================================================================
75
+ -- Path helpers (duplicated from mcp_bridge.lua — standalone script)
76
+ -- =============================================================================
77
+
78
+ local function get_snapshot_dir()
79
+ local proj_path = reaper.GetProjectPath()
80
+ if proj_path and proj_path ~= "" then
81
+ return proj_path .. "/.reaper-mcp/snapshots/"
82
+ end
83
+ -- Fallback for unsaved projects: use bridge data dir
84
+ return reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/snapshots/"
85
+ end
86
+
87
+ local function ensure_snapshot_dir()
88
+ reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
89
+ end
90
+
91
+ local function snapshot_path(name)
92
+ local safe = name:gsub("[^%w%-_%.%s]", "_"):gsub("%s+", "_")
93
+ return get_snapshot_dir() .. safe .. ".json"
94
+ end
95
+
96
+ -- =============================================================================
97
+ -- Minimal JSON helpers (enough for snapshot format)
98
+ -- =============================================================================
99
+
100
+ local function json_encode_string(s)
101
+ s = tostring(s)
102
+ s = s:gsub('\\', '\\\\')
103
+ s = s:gsub('"', '\\"')
104
+ s = s:gsub('\n', '\\n')
105
+ s = s:gsub('\r', '\\r')
106
+ s = s:gsub('\t', '\\t')
107
+ return '"' .. s .. '"'
108
+ end
109
+
110
+ local function json_encode(val)
111
+ local t = type(val)
112
+ if t == "nil" then
113
+ return "null"
114
+ elseif t == "boolean" then
115
+ return val and "true" or "false"
116
+ elseif t == "number" then
117
+ if val ~= val then return "null" end -- NaN
118
+ return tostring(val)
119
+ elseif t == "string" then
120
+ return json_encode_string(val)
121
+ elseif t == "table" then
122
+ -- Check if array
123
+ local is_array = true
124
+ local max_i = 0
125
+ for k, _ in pairs(val) do
126
+ if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
127
+ is_array = false
128
+ break
129
+ end
130
+ if k > max_i then max_i = k end
131
+ end
132
+ if is_array and max_i == #val then
133
+ local parts = {}
134
+ for i = 1, #val do
135
+ parts[i] = json_encode(val[i])
136
+ end
137
+ return "[" .. table.concat(parts, ",") .. "]"
138
+ else
139
+ local parts = {}
140
+ for k, v in pairs(val) do
141
+ parts[#parts + 1] = json_encode_string(tostring(k)) .. ":" .. json_encode(v)
142
+ end
143
+ return "{" .. table.concat(parts, ",") .. "}"
144
+ end
145
+ end
146
+ return "null"
147
+ end
148
+
149
+ -- Extract a string value from JSON (handles escape sequences)
150
+ local function extract_json_string(str, pos)
151
+ local result = {}
152
+ local i = pos
153
+ while i <= #str do
154
+ local ch = str:sub(i, i)
155
+ if ch == '\\' then
156
+ local next_ch = str:sub(i + 1, i + 1)
157
+ if next_ch == '"' then result[#result + 1] = '"'
158
+ elseif next_ch == '\\' then result[#result + 1] = '\\'
159
+ elseif next_ch == 'n' then result[#result + 1] = '\n'
160
+ elseif next_ch == 't' then result[#result + 1] = '\t'
161
+ elseif next_ch == '/' then result[#result + 1] = '/'
162
+ else result[#result + 1] = next_ch
163
+ end
164
+ i = i + 2
165
+ elseif ch == '"' then
166
+ return table.concat(result), i + 1
167
+ else
168
+ result[#result + 1] = ch
169
+ i = i + 1
170
+ end
171
+ end
172
+ return nil, i
173
+ end
174
+
175
+ -- Parse a flat JSON object (sufficient for top-level snapshot metadata)
176
+ local function parse_flat_object(str)
177
+ local obj = {}
178
+ local i = 1
179
+ while i <= #str do
180
+ local key_start = str:find('"', i)
181
+ if not key_start then break end
182
+ local key, after_key = extract_json_string(str, key_start + 1)
183
+ if not key then break end
184
+ i = after_key
185
+
186
+ local colon = str:find(':', i)
187
+ if not colon then break end
188
+ local val_start = str:match('^%s*()', colon + 1)
189
+
190
+ local ch = str:sub(val_start, val_start)
191
+ if ch == '"' then
192
+ local val, next_pos = extract_json_string(str, val_start + 1)
193
+ if val then
194
+ obj[key] = val
195
+ i = next_pos
196
+ else
197
+ i = val_start + 1
198
+ end
199
+ elseif ch == '{' or ch == '[' then
200
+ -- Skip nested objects/arrays — not needed for top-level metadata
201
+ local depth = 1
202
+ local open_c = ch
203
+ local close_c = ch == '{' and '}' or ']'
204
+ local j = val_start + 1
205
+ while j <= #str and depth > 0 do
206
+ local c = str:sub(j, j)
207
+ if c == open_c then depth = depth + 1
208
+ elseif c == close_c then depth = depth - 1 end
209
+ j = j + 1
210
+ end
211
+ -- Store raw (won't be used for top-level display)
212
+ i = j
213
+ else
214
+ -- Number, boolean, null
215
+ local val_end = str:find('[,}%]]', val_start)
216
+ if not val_end then val_end = #str + 1 end
217
+ local raw = str:sub(val_start, val_end - 1):match("^%s*(.-)%s*$")
218
+ if raw == "true" then obj[key] = true
219
+ elseif raw == "false" then obj[key] = false
220
+ elseif raw == "null" then obj[key] = nil
221
+ else obj[key] = tonumber(raw)
222
+ end
223
+ i = val_end
224
+ end
225
+ end
226
+ return obj
227
+ end
228
+
229
+ -- Count tracks in mixerState.tracks array (rough count from string)
230
+ local function count_tracks_in_snapshot(content)
231
+ local mixer_start = content:find('"mixerState"')
232
+ if not mixer_start then return nil end
233
+ local tracks_start = content:find('"tracks"', mixer_start)
234
+ if not tracks_start then return nil end
235
+ local arr_start = content:find('%[', tracks_start)
236
+ if not arr_start then return nil end
237
+ -- Count top-level objects in the array
238
+ local count = 0
239
+ local depth = 0
240
+ local i = arr_start
241
+ while i <= #arr_start + 50000 and i <= #content do
242
+ local ch = content:sub(i, i)
243
+ if ch == '[' or ch == '{' then
244
+ depth = depth + 1
245
+ if ch == '{' and depth == 2 then count = count + 1 end
246
+ elseif ch == ']' or ch == '}' then
247
+ depth = depth - 1
248
+ if depth == 0 then break end
249
+ end
250
+ i = i + 1
251
+ end
252
+ return count > 0 and count or nil
253
+ end
254
+
255
+ -- =============================================================================
256
+ -- Snapshot I/O
257
+ -- =============================================================================
258
+
259
+ local function read_file(path)
260
+ local f = io.open(path, "rb")
261
+ if not f then return nil end
262
+ local content = f:read("*a")
263
+ f:close()
264
+ return content
265
+ end
266
+
267
+ local function write_file(path, content)
268
+ local f = io.open(path, "wb")
269
+ if not f then return false end
270
+ f:write(content)
271
+ f:close()
272
+ return true
273
+ end
274
+
275
+ local function load_snapshots()
276
+ ensure_snapshot_dir()
277
+ local snap_dir = get_snapshot_dir()
278
+ local result = {}
279
+
280
+ local i = 0
281
+ while true do
282
+ local fn = reaper.EnumerateFiles(snap_dir, i)
283
+ if not fn then break end
284
+ if fn:match("%.json$") then
285
+ local path = snap_dir .. fn
286
+ local content = read_file(path)
287
+ if content then
288
+ local ok, snap = pcall(parse_flat_object, content)
289
+ if ok and snap and snap.name then
290
+ -- Try to count tracks
291
+ local track_count = nil
292
+ pcall(function()
293
+ track_count = count_tracks_in_snapshot(content)
294
+ end)
295
+ result[#result + 1] = {
296
+ name = snap.name,
297
+ description = snap.description or "",
298
+ timestamp = tonumber(snap.timestamp) or 0,
299
+ trackCount = track_count,
300
+ path = path,
301
+ }
302
+ end
303
+ end
304
+ end
305
+ i = i + 1
306
+ end
307
+
308
+ -- Sort by timestamp descending (newest first)
309
+ table.sort(result, function(a, b) return a.timestamp > b.timestamp end)
310
+ return result
311
+ end
312
+
313
+ local function format_timestamp(ts_ms)
314
+ if not ts_ms or ts_ms == 0 then return "Unknown" end
315
+ local ts = math.floor(ts_ms / 1000)
316
+ return os.date("%Y-%m-%d %H:%M", ts)
317
+ end
318
+
319
+ -- =============================================================================
320
+ -- Capture mixer state (standalone — duplicated from bridge for self-containment)
321
+ -- =============================================================================
322
+
323
+ local function capture_mixer_state()
324
+ local state = { version = 2, tracks = {} }
325
+ local count = reaper.CountTracks(0)
326
+ for i = 0, count - 1 do
327
+ local track = reaper.GetTrack(0, i)
328
+ local _, name = reaper.GetTrackName(track)
329
+ local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
330
+ local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
331
+ local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
332
+ local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
333
+ local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
334
+
335
+ local fx_count = reaper.TrackFX_GetCount(track)
336
+ local fx_states = {}
337
+ for j = 0, fx_count - 1 do
338
+ local enabled = reaper.TrackFX_GetEnabled(track, j)
339
+ local offline = reaper.TrackFX_GetOffline(track, j)
340
+ local _, preset = reaper.TrackFX_GetPreset(track, j)
341
+ local _, fx_name = reaper.TrackFX_GetFXName(track, j)
342
+ local params_data = {}
343
+ local param_count = reaper.TrackFX_GetNumParams(track, j)
344
+ local limit = math.min(param_count, 500)
345
+ for p = 0, limit - 1 do
346
+ params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
347
+ end
348
+ fx_states[j + 1] = {
349
+ name = fx_name, enabled = enabled, offline = offline,
350
+ preset = preset or "", params = params_data,
351
+ }
352
+ end
353
+
354
+ local send_count = reaper.GetTrackNumSends(track, 0)
355
+ local sends = {}
356
+ for s = 0, send_count - 1 do
357
+ local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
358
+ local dest_idx = -1
359
+ local dest_name = ""
360
+ if dest_track then
361
+ dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
362
+ local _, dname = reaper.GetTrackName(dest_track)
363
+ dest_name = dname or ""
364
+ end
365
+ sends[s + 1] = {
366
+ destTrackIndex = dest_idx, destTrackName = dest_name,
367
+ volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
368
+ pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
369
+ muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
370
+ }
371
+ end
372
+
373
+ local fx_enabled = {}
374
+ for j = 1, #fx_states do fx_enabled[j] = fx_states[j].enabled end
375
+
376
+ state.tracks[i + 1] = {
377
+ index = i, name = name, color = color,
378
+ volume = vol, pan = pan,
379
+ mute = mute ~= 0, solo = solo ~= 0,
380
+ fx = fx_states, sends = sends, fxEnabled = fx_enabled,
381
+ }
382
+ end
383
+ return state
384
+ end
385
+
386
+ local function do_save_snapshot(name, description)
387
+ ensure_snapshot_dir()
388
+ local timestamp = os.time() * 1000
389
+ local snapshot = {
390
+ name = name,
391
+ description = description or "",
392
+ timestamp = timestamp,
393
+ mixerState = capture_mixer_state(),
394
+ }
395
+ local path = snapshot_path(name)
396
+ local ok = write_file(path, json_encode(snapshot))
397
+ if not ok then
398
+ return false, "Failed to write file: " .. path
399
+ end
400
+ return true, nil
401
+ end
402
+
403
+ local function do_restore_snapshot(snap)
404
+ local content = read_file(snap.path)
405
+ if not content then
406
+ return false, "File not found: " .. snap.path
407
+ end
408
+
409
+ -- We need deeper parsing for restore — use the full flat parser on the file
410
+ -- For simplicity, show a message and use the MCP bridge if available;
411
+ -- otherwise we do a best-effort restore.
412
+ local ok_parse, parsed = pcall(parse_flat_object, content)
413
+ if not ok_parse or not parsed then
414
+ return false, "Could not parse snapshot file"
415
+ end
416
+
417
+ -- The mixerState is a nested object — not directly accessible via parse_flat_object.
418
+ -- We'll do a targeted string extraction of the "mixerState" block and parse it.
419
+ -- For v2 snapshots this is complex; we perform a simplified restore of
420
+ -- volume/pan/mute/solo from a targeted parse.
421
+ local mixer_block_start = content:find('"mixerState"%s*:%s*{')
422
+ if not mixer_block_start then
423
+ return false, "No mixerState found in snapshot"
424
+ end
425
+
426
+ -- Find "tracks" array
427
+ local tracks_start = content:find('"tracks"%s*:%s*%[', mixer_block_start)
428
+ if not tracks_start then
429
+ return false, "No tracks found in snapshot"
430
+ end
431
+
432
+ local arr_open = content:find('%[', tracks_start)
433
+ if not arr_open then return false, "Invalid tracks format" end
434
+
435
+ -- Extract each track object
436
+ local track_objects = {}
437
+ local depth = 0
438
+ local obj_start = nil
439
+ local i = arr_open
440
+
441
+ while i <= #content do
442
+ local ch = content:sub(i, i)
443
+ if ch == '{' then
444
+ depth = depth + 1
445
+ if depth == 1 then obj_start = i end
446
+ elseif ch == '}' then
447
+ depth = depth - 1
448
+ if depth == 0 and obj_start then
449
+ track_objects[#track_objects + 1] = content:sub(obj_start, i)
450
+ obj_start = nil
451
+ end
452
+ elseif ch == ']' and depth == 0 then
453
+ break
454
+ end
455
+ i = i + 1
456
+ end
457
+
458
+ if #track_objects == 0 then
459
+ return false, "No track data found in snapshot"
460
+ end
461
+
462
+ reaper.Undo_BeginBlock()
463
+ local restored = 0
464
+
465
+ for _, track_json in ipairs(track_objects) do
466
+ local ok_t, track_data = pcall(parse_flat_object, track_json)
467
+ if ok_t and track_data and track_data.index then
468
+ local track_idx = tonumber(track_data.index)
469
+ local track = reaper.GetTrack(0, track_idx)
470
+ if track then
471
+ if track_data.volume then
472
+ reaper.SetMediaTrackInfo_Value(track, "D_VOL", tonumber(track_data.volume))
473
+ end
474
+ if track_data.pan then
475
+ reaper.SetMediaTrackInfo_Value(track, "D_PAN", tonumber(track_data.pan))
476
+ end
477
+ if track_data.mute ~= nil then
478
+ local mute_val = (track_data.mute == true or track_data.mute == "true") and 1 or 0
479
+ reaper.SetMediaTrackInfo_Value(track, "B_MUTE", mute_val)
480
+ end
481
+ if track_data.solo ~= nil then
482
+ local solo_val = (track_data.solo == true or track_data.solo == "true") and 1 or 0
483
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", solo_val)
484
+ end
485
+ restored = restored + 1
486
+ end
487
+ end
488
+ end
489
+
490
+ reaper.Undo_EndBlock("MCP Snapshot Manager: Restore '" .. snap.name .. "'", -1)
491
+ reaper.TrackList_AdjustWindows(false)
492
+ reaper.UpdateArrange()
493
+
494
+ return true, nil, restored
495
+ end
496
+
497
+ local function do_delete_snapshot(snap)
498
+ local ok, err = os.remove(snap.path)
499
+ if not ok then
500
+ return false, "Failed to delete: " .. (err or "unknown error")
501
+ end
502
+ return true, nil
503
+ end
504
+
505
+ -- =============================================================================
506
+ -- UI helpers
507
+ -- =============================================================================
508
+
509
+ local function set_color(c)
510
+ gfx.r, gfx.g, gfx.b, gfx.a = c[1], c[2], c[3], c[4]
511
+ end
512
+
513
+ local function fill_rect(x, y, w, h)
514
+ gfx.rect(x, y, w, h, 1)
515
+ end
516
+
517
+ local function draw_rect(x, y, w, h)
518
+ gfx.rect(x, y, w, h, 0)
519
+ end
520
+
521
+ local function draw_text(text, x, y, color, align_right, max_w)
522
+ set_color(color or C_TEXT)
523
+ if max_w and max_w > 0 then
524
+ -- Clip text to max_w (simple truncation)
525
+ while #text > 1 do
526
+ local tw, _ = gfx.measurestr(text)
527
+ if tw <= max_w then break end
528
+ text = text:sub(1, -2)
529
+ end
530
+ end
531
+ if align_right then
532
+ local tw, _ = gfx.measurestr(text)
533
+ x = x - tw
534
+ end
535
+ gfx.x, gfx.y = x, y
536
+ gfx.drawstr(text)
537
+ end
538
+
539
+ local function draw_button(label, x, y, w, h, bg_normal, bg_hover, is_hover, is_pressed)
540
+ local bg = bg_normal
541
+ if is_pressed then bg = C_BTN_PRESS
542
+ elseif is_hover then bg = bg_hover or C_BTN_HOVER
543
+ end
544
+ set_color(bg)
545
+ fill_rect(x, y, w, h)
546
+ -- Border
547
+ set_color(C_DIVIDER)
548
+ draw_rect(x, y, w, h)
549
+ -- Label centered
550
+ local tw, th = gfx.measurestr(label)
551
+ draw_text(label, x + math.floor((w - tw) / 2), y + math.floor((h - th) / 2), C_TEXT_SEL)
552
+ end
553
+
554
+ local function point_in_rect(px, py, rx, ry, rw, rh)
555
+ return px >= rx and px < rx + rw and py >= ry and py < ry + rh
556
+ end
557
+
558
+ -- =============================================================================
559
+ -- Layout calculations
560
+ -- =============================================================================
561
+
562
+ local function get_list_rect()
563
+ local w = gfx.w
564
+ local h = gfx.h
565
+ local list_x = PADDING
566
+ local list_y = LIST_TOP
567
+ local list_w = w - PADDING * 2
568
+ local list_h = h - LIST_TOP - LIST_BOTTOM_MARGIN - PADDING
569
+ return list_x, list_y, list_w, list_h
570
+ end
571
+
572
+ local function get_visible_rows(list_h)
573
+ return math.floor(list_h / ROW_H)
574
+ end
575
+
576
+ local function get_button_rects()
577
+ local h = gfx.h
578
+ local w = gfx.w
579
+ local btn_y = h - FOOTER_H + math.floor((FOOTER_H - BUTTON_H) / 2)
580
+ local btns = {}
581
+ local x = PADDING
582
+
583
+ btns.save = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Save New" }
584
+ x = x + BUTTON_W + PADDING
585
+
586
+ btns.restore = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Restore" }
587
+ x = x + BUTTON_W + PADDING
588
+
589
+ btns.delete = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Delete" }
590
+ x = x + BUTTON_W + PADDING
591
+
592
+ btns.refresh = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Refresh" }
593
+
594
+ -- Close button on right side
595
+ btns.close = { x = w - BUTTON_W - PADDING, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Close" }
596
+
597
+ return btns
598
+ end
599
+
600
+ -- =============================================================================
601
+ -- Status message helpers
602
+ -- =============================================================================
603
+
604
+ local function set_status(msg, stype, duration)
605
+ status_msg = msg
606
+ status_type = stype or "info"
607
+ status_timer = reaper.time_precise() + (duration or 4.0)
608
+ end
609
+
610
+ -- =============================================================================
611
+ -- Main draw function
612
+ -- =============================================================================
613
+
614
+ local function draw()
615
+ local w = gfx.w
616
+ local h = gfx.h
617
+ local mx = gfx.mouse_x
618
+ local my = gfx.mouse_y
619
+ local mouse_cap = gfx.mouse_cap
620
+ local mouse_down = (mouse_cap & 1) ~= 0
621
+
622
+ -- Background
623
+ set_color(C_BG)
624
+ fill_rect(0, 0, w, h)
625
+
626
+ -- Header bar
627
+ set_color(C_HEADER_BG)
628
+ fill_rect(0, 0, w, HEADER_H)
629
+ gfx.setfont(1, "Arial", 16, string.byte('b'))
630
+ draw_text(TITLE, PADDING, math.floor((HEADER_H - 16) / 2), C_TEXT_SEL)
631
+ gfx.setfont(1, "Arial", 12, 0)
632
+
633
+ -- Snapshot dir info
634
+ local snap_dir = get_snapshot_dir()
635
+ local dir_label = "Dir: " .. snap_dir
636
+ draw_text(dir_label, PADDING + 200, math.floor((HEADER_H - 12) / 2) + 2, C_TEXT_DIM, false, w - 220 - PADDING)
637
+
638
+ -- Column headers
639
+ local lx, ly, lw, lh = get_list_rect()
640
+ local col_header_y = ly - ROW_H - 2
641
+ set_color(C_HEADER_BG)
642
+ fill_rect(lx, col_header_y, lw - SCROLL_BAR_W, ROW_H)
643
+ gfx.setfont(1, "Arial", 11, string.byte('b'))
644
+ local cx = lx + 4
645
+ draw_text("Name", cx, col_header_y + 7, C_TEXT_HEADER)
646
+ cx = cx + COL_NAME_W
647
+ draw_text("Description", cx, col_header_y + 7, C_TEXT_HEADER)
648
+ cx = cx + COL_DESC_W
649
+ draw_text("Saved", cx, col_header_y + 7, C_TEXT_HEADER)
650
+ cx = cx + COL_DATE_W
651
+ draw_text("Tracks", cx, col_header_y + 7, C_TEXT_HEADER)
652
+ gfx.setfont(1, "Arial", 12, 0)
653
+
654
+ -- Divider under col headers
655
+ set_color(C_DIVIDER)
656
+ fill_rect(lx, col_header_y + ROW_H - 1, lw, 1)
657
+
658
+ -- Snapshot list
659
+ local visible_rows = get_visible_rows(lh)
660
+ local total_rows = #snapshots
661
+
662
+ -- Clamp scroll
663
+ local max_scroll = math.max(0, total_rows - visible_rows)
664
+ if scroll_offset > max_scroll then scroll_offset = max_scroll end
665
+ if scroll_offset < 0 then scroll_offset = 0 end
666
+
667
+ hover_row = 0
668
+
669
+ for row = 1, visible_rows do
670
+ local snap_idx = row + scroll_offset
671
+ local ry = ly + (row - 1) * ROW_H
672
+ if ry + ROW_H > ly + lh then break end
673
+
674
+ local row_w = lw - SCROLL_BAR_W
675
+
676
+ if snap_idx <= total_rows then
677
+ local snap = snapshots[snap_idx]
678
+ local is_sel = snap_idx == selected_idx
679
+ local is_hov = point_in_rect(mx, my, lx, ry, row_w, ROW_H)
680
+
681
+ if is_hov then hover_row = snap_idx end
682
+
683
+ -- Row background
684
+ if is_sel then set_color(C_ROW_SEL)
685
+ elseif is_hov then set_color(C_ROW_HOVER)
686
+ elseif row % 2 == 0 then set_color(C_ROW_EVEN)
687
+ else set_color(C_ROW_ODD)
688
+ end
689
+ fill_rect(lx, ry, row_w, ROW_H)
690
+
691
+ -- Row content
692
+ local text_color = is_sel and C_TEXT_SEL or C_TEXT
693
+ local dim_color = is_sel and C_TEXT_SEL or C_TEXT_DIM
694
+ local text_y = ry + math.floor((ROW_H - 12) / 2)
695
+
696
+ local cell_x = lx + 4
697
+ draw_text(snap.name, cell_x, text_y, text_color, false, COL_NAME_W - 8)
698
+
699
+ cell_x = cell_x + COL_NAME_W
700
+ draw_text(snap.description, cell_x, text_y, dim_color, false, COL_DESC_W - 8)
701
+
702
+ cell_x = cell_x + COL_DESC_W
703
+ draw_text(format_timestamp(snap.timestamp), cell_x, text_y, dim_color, false, COL_DATE_W - 8)
704
+
705
+ cell_x = cell_x + COL_DATE_W
706
+ local tc_str = snap.trackCount and tostring(snap.trackCount) or "-"
707
+ draw_text(tc_str, cell_x, text_y, dim_color)
708
+
709
+ -- Row separator
710
+ set_color(C_DIVIDER)
711
+ fill_rect(lx, ry + ROW_H - 1, row_w, 1)
712
+ else
713
+ -- Empty row
714
+ local bg = (row % 2 == 0) and C_ROW_EVEN or C_ROW_ODD
715
+ set_color(bg)
716
+ fill_rect(lx, ry, row_w, ROW_H)
717
+ end
718
+ end
719
+
720
+ -- Empty state message
721
+ if total_rows == 0 then
722
+ local msg = "No snapshots found. Use 'Save New' to create one."
723
+ gfx.setfont(1, "Arial", 13, 0)
724
+ local tw, _ = gfx.measurestr(msg)
725
+ draw_text(msg, lx + math.floor((lw - tw) / 2), ly + math.floor(lh / 2) - 8, C_TEXT_DIM)
726
+ gfx.setfont(1, "Arial", 12, 0)
727
+ end
728
+
729
+ -- Scrollbar
730
+ if total_rows > visible_rows then
731
+ local sb_x = lx + lw - SCROLL_BAR_W
732
+ local sb_y = ly
733
+ local sb_h = lh
734
+
735
+ set_color(C_SCROLLBAR)
736
+ fill_rect(sb_x, sb_y, SCROLL_BAR_W, sb_h)
737
+
738
+ local thumb_h = math.max(20, math.floor(sb_h * visible_rows / total_rows))
739
+ local thumb_y = sb_y + math.floor((sb_h - thumb_h) * scroll_offset / math.max(1, max_scroll))
740
+
741
+ set_color(C_SCROLLTHUMB)
742
+ fill_rect(sb_x + 2, thumb_y + 1, SCROLL_BAR_W - 4, thumb_h - 2)
743
+ end
744
+
745
+ -- Footer divider
746
+ set_color(C_DIVIDER)
747
+ fill_rect(0, h - FOOTER_H - STATUS_H, w, 1)
748
+
749
+ -- Status bar
750
+ local status_y = h - STATUS_H - 2
751
+ set_color(C_BG)
752
+ fill_rect(0, status_y, w, STATUS_H)
753
+
754
+ if status_msg ~= "" and reaper.time_precise() < status_timer then
755
+ local sc = C_STATUS_INFO
756
+ if status_type == "ok" then sc = C_STATUS_OK
757
+ elseif status_type == "err" then sc = C_STATUS_ERR
758
+ end
759
+ gfx.setfont(1, "Arial", 11, 0)
760
+ draw_text(status_msg, PADDING, status_y + 4, sc)
761
+ gfx.setfont(1, "Arial", 12, 0)
762
+ end
763
+
764
+ -- Footer buttons
765
+ set_color(C_BG)
766
+ fill_rect(0, h - FOOTER_H, w, FOOTER_H)
767
+ set_color(C_DIVIDER)
768
+ fill_rect(0, h - FOOTER_H, w, 1)
769
+
770
+ local btns = get_button_rects()
771
+ hover_btn = nil
772
+
773
+ local has_selection = selected_idx > 0 and selected_idx <= #snapshots
774
+
775
+ for btn_name, btn in pairs(btns) do
776
+ local is_hov = point_in_rect(mx, my, btn.x, btn.y, btn.w, btn.h)
777
+ if is_hov then hover_btn = btn_name end
778
+ local is_press = btn_pressed == btn_name
779
+
780
+ -- Disable restore/delete if nothing selected
781
+ local disabled = (btn_name == "restore" or btn_name == "delete") and not has_selection
782
+
783
+ local bg_n = C_BTN_NORMAL
784
+ local bg_h = C_BTN_HOVER
785
+ if btn_name == "delete" then
786
+ bg_n = has_selection and C_BTN_DELETE or C_BTN_NORMAL
787
+ bg_h = C_BTN_DEL_HOV
788
+ elseif btn_name == "restore" then
789
+ bg_n = has_selection and C_BTN_RESTORE or C_BTN_NORMAL
790
+ bg_h = C_BTN_RES_HOV
791
+ end
792
+
793
+ if disabled then
794
+ set_color({ bg_n[1] * 0.6, bg_n[2] * 0.6, bg_n[3] * 0.6, 1.0 })
795
+ fill_rect(btn.x, btn.y, btn.w, btn.h)
796
+ set_color(C_DIVIDER)
797
+ draw_rect(btn.x, btn.y, btn.w, btn.h)
798
+ local tw, th = gfx.measurestr(btn.label)
799
+ draw_text(btn.label, btn.x + math.floor((btn.w - tw) / 2), btn.y + math.floor((btn.h - th) / 2), C_TEXT_DIM)
800
+ else
801
+ draw_button(btn.label, btn.x, btn.y, btn.w, btn.h, bg_n, bg_h, is_hov and not disabled, is_press)
802
+ end
803
+ end
804
+
805
+ -- Mouse interaction
806
+ local mouse_clicked = mouse_was_down and not mouse_down
807
+
808
+ if mouse_clicked then
809
+ -- Check if clicked on list row
810
+ if hover_row > 0 and hover_row <= total_rows then
811
+ selected_idx = hover_row
812
+ end
813
+
814
+ -- Check button clicks
815
+ if hover_btn and btn_pressed == hover_btn then
816
+ local has_sel = selected_idx > 0 and selected_idx <= #snapshots
817
+
818
+ if hover_btn == "save" then
819
+ -- Show save dialog
820
+ local retval, inputs = reaper.GetUserInputs(
821
+ "Save Snapshot", 2,
822
+ "Name:,Description:,extrawidth=200",
823
+ ","
824
+ )
825
+ if retval then
826
+ local fields = {}
827
+ for part in (inputs .. ","):gmatch("([^,]*),") do
828
+ fields[#fields + 1] = part
829
+ end
830
+ local sname = fields[1] and fields[1]:match("^%s*(.-)%s*$") or ""
831
+ local sdesc = fields[2] and fields[2]:match("^%s*(.-)%s*$") or ""
832
+ if sname == "" then
833
+ set_status("Name cannot be empty.", "err")
834
+ else
835
+ local ok, err = do_save_snapshot(sname, sdesc)
836
+ if ok then
837
+ set_status("Saved snapshot: " .. sname, "ok")
838
+ snapshots = load_snapshots()
839
+ -- Select the newly saved snapshot
840
+ for j, s in ipairs(snapshots) do
841
+ if s.name == sname then selected_idx = j; break end
842
+ end
843
+ else
844
+ set_status("Save failed: " .. (err or "unknown error"), "err")
845
+ end
846
+ end
847
+ end
848
+
849
+ elseif hover_btn == "restore" and has_sel then
850
+ local snap = snapshots[selected_idx]
851
+ local confirm = reaper.ShowMessageBox(
852
+ "Restore snapshot '" .. snap.name .. "'?\n\nThis will overwrite current mixer state.\nAn undo point will be created.",
853
+ "Restore Snapshot", 1 -- MB_OKCANCEL
854
+ )
855
+ if confirm == 1 then
856
+ local ok, err, count = do_restore_snapshot(snap)
857
+ if ok then
858
+ set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
859
+ else
860
+ set_status("Restore failed: " .. (err or "unknown error"), "err")
861
+ end
862
+ end
863
+
864
+ elseif hover_btn == "delete" and has_sel then
865
+ local snap = snapshots[selected_idx]
866
+ local confirm = reaper.ShowMessageBox(
867
+ "Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
868
+ "Delete Snapshot", 1 -- MB_OKCANCEL
869
+ )
870
+ if confirm == 1 then
871
+ local ok, err = do_delete_snapshot(snap)
872
+ if ok then
873
+ set_status("Deleted snapshot: " .. snap.name, "ok")
874
+ snapshots = load_snapshots()
875
+ if selected_idx > #snapshots then
876
+ selected_idx = #snapshots
877
+ end
878
+ else
879
+ set_status("Delete failed: " .. (err or "unknown error"), "err")
880
+ end
881
+ end
882
+
883
+ elseif hover_btn == "refresh" then
884
+ snapshots = load_snapshots()
885
+ set_status("Refreshed — " .. #snapshots .. " snapshot(s) found.", "info", 2.5)
886
+
887
+ elseif hover_btn == "close" then
888
+ is_running = false
889
+ end
890
+ end
891
+
892
+ btn_pressed = nil
893
+ end
894
+
895
+ if mouse_down and not mouse_was_down then
896
+ -- Start press
897
+ if hover_btn then
898
+ btn_pressed = hover_btn
899
+ end
900
+ end
901
+
902
+ mouse_was_down = mouse_down
903
+ last_mouse_x = mx
904
+ last_mouse_y = my
905
+ last_mouse_cap = mouse_cap
906
+
907
+ -- Keyboard navigation
908
+ local char = gfx.getchar()
909
+ if char == -1 then
910
+ is_running = false -- Window closed
911
+ elseif char == 27 then -- Escape
912
+ is_running = false
913
+ elseif char == 1685026670 or char == 30064 then -- Up arrow
914
+ if selected_idx > 1 then
915
+ selected_idx = selected_idx - 1
916
+ if selected_idx <= scroll_offset then
917
+ scroll_offset = selected_idx - 1
918
+ end
919
+ end
920
+ elseif char == 1685026669 or char == 30065 then -- Down arrow
921
+ if selected_idx < #snapshots then
922
+ selected_idx = selected_idx + 1
923
+ local _, _, _, lh2 = get_list_rect()
924
+ local vr = get_visible_rows(lh2)
925
+ if selected_idx > scroll_offset + vr then
926
+ scroll_offset = selected_idx - vr
927
+ end
928
+ end
929
+ elseif char == 13 then -- Enter = restore
930
+ if selected_idx > 0 and selected_idx <= #snapshots then
931
+ local snap = snapshots[selected_idx]
932
+ local ok, err, count = do_restore_snapshot(snap)
933
+ if ok then
934
+ set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
935
+ else
936
+ set_status("Restore failed: " .. (err or "unknown error"), "err")
937
+ end
938
+ end
939
+ elseif char == 6579564 then -- Delete key
940
+ if selected_idx > 0 and selected_idx <= #snapshots then
941
+ local snap = snapshots[selected_idx]
942
+ local confirm = reaper.ShowMessageBox(
943
+ "Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
944
+ "Delete Snapshot", 1
945
+ )
946
+ if confirm == 1 then
947
+ local ok, err = do_delete_snapshot(snap)
948
+ if ok then
949
+ set_status("Deleted snapshot: " .. snap.name, "ok")
950
+ snapshots = load_snapshots()
951
+ if selected_idx > #snapshots then selected_idx = #snapshots end
952
+ else
953
+ set_status("Delete failed: " .. (err or "unknown error"), "err")
954
+ end
955
+ end
956
+ end
957
+ end
958
+
959
+ -- Mouse wheel scroll
960
+ local wheel = gfx.mouse_wheel
961
+ if wheel ~= 0 then
962
+ local scroll_lines = wheel > 0 and -3 or 3
963
+ scroll_offset = math.max(0, math.min(scroll_offset + scroll_lines, math.max(0, #snapshots - get_visible_rows((select(4, get_list_rect()) )))))
964
+ gfx.mouse_wheel = 0
965
+ end
966
+
967
+ gfx.update()
968
+ end
969
+
970
+ -- =============================================================================
971
+ -- Defer loop
972
+ -- =============================================================================
973
+
974
+ local function loop()
975
+ if not is_running then
976
+ gfx.quit()
977
+ return
978
+ end
979
+ draw()
980
+ reaper.defer(loop)
981
+ end
982
+
983
+ -- =============================================================================
984
+ -- Initialization
985
+ -- =============================================================================
986
+
987
+ gfx.init(TITLE, WIN_W, WIN_H, 0)
988
+ gfx.setfont(1, "Arial", 12, 0)
989
+
990
+ snapshots = load_snapshots()
991
+ if #snapshots > 0 then
992
+ selected_idx = 1
993
+ end
994
+
995
+ set_status("Loaded " .. #snapshots .. " snapshot(s) from " .. get_snapshot_dir(), "info", 5.0)
996
+
997
+ reaper.defer(loop)