Skip to content

Commit 55a4495

Browse files
committed
Companion: volume variables, split volume feedbacks, macOS system volume in state
- api: read macOS output volume into STATUS after each getState refresh - module 2.4.0 (unchanged version): volume + volume_percent variables - Feedbacks: Volume below / between / above with full style options - Preset + HELP + CHANGELOG (notes under 2.4.0) Made-with: Cursor
1 parent 9283bb8 commit 55a4495

6 files changed

Lines changed: 320 additions & 8 deletions

File tree

api.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ function setSystemMuted(muted) {
5656
return osascript(script)
5757
}
5858

59+
/** macOS system output volume 0–100 (matches setVolume / volume up-down). */
60+
function readMacOutputVolume() {
61+
const script = `output volume of (get volume settings)`
62+
return osascript(script).then(function (result) {
63+
const n = parseInt(String(result).trim(), 10)
64+
return Number.isNaN(n) ? null : Math.min(100, Math.max(0, n))
65+
})
66+
}
67+
5968
function openSpotifyUri(uri) {
6069
if (isWindows) {
6170
exec(`start ${uri}`, (err) => {
@@ -84,8 +93,22 @@ function getState(callback) {
8493

8594
spotify.isShuffling(function (err, shuffling) {
8695
global.STATUS.state.isShuffling = shuffling
87-
updateClients()
88-
if (typeof callback === 'function') callback()
96+
function finishState() {
97+
updateClients()
98+
if (typeof callback === 'function') callback()
99+
}
100+
if (isMac) {
101+
readMacOutputVolume()
102+
.then(function (vol) {
103+
if (vol !== null) global.STATUS.state.volume = vol
104+
})
105+
.catch(function (err) {
106+
console.error('Read system output volume failed:', err)
107+
})
108+
.finally(finishState)
109+
} else {
110+
finishState()
111+
}
89112
})
90113
})
91114
} else {

companion-module/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Companion module changelog
2+
3+
All entries below are shipped as **module version 2.4.0** (manifest / `package.json` unchanged from that number).
4+
5+
## 2.4.0
6+
7+
- **Variables:** `volume` (0–100), `volume_percent` (e.g. `72%`); volume reflects macOS **system output** when the host app reports it.
8+
- **Feedbacks:** `Volume: below`, `Volume: between`, `Volume: above` — stack multiple instances; each has color pickers, text size, alignment, top bar, and optional button text.
9+
- **Preset:** “Volume Level” uses the three volume feedbacks plus `volume_percent` in the label.
10+
11+
Host app (`spotify-controller`): after each state refresh on macOS, system output volume is read so Companion stays aligned with volume actions.

companion-module/companion/HELP.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,20 @@ _(All available on Mac; limited on Windows)_
4545
- **Current Track Playback Position**
4646
- **Track ID**
4747
- **Player State**
48-
- **Current Volume Level**
48+
- **Volume (0–100)** — numeric level from macOS system output _(Mac; matches Set Volume actions)_
49+
- **Volume with % (e.g. 72%)** — same value with a % suffix for button labels
4950

5051
## Available Feedbacks
5152

5253
- Change button color if playback is in **X** state (Playing, Paused, Stopped) _(Mac only)_
54+
- **Volume: below** — when volume is strictly below a threshold, apply your chosen style (colors, text size, alignment, optional text, top bar) _(Mac only)_
55+
- **Volume: between** — when volume is between two values (inclusive), apply style _(Mac only)_
56+
- **Volume: above** — when volume is strictly above a threshold, apply style _(Mac only)_
57+
Stack several of these on one button for multi-zone styling (e.g. green / orange / red bands).
5358

5459
## Available Presets
5560

5661
- Play/Pause (with icons)
5762
- Volume Up/Down/50%/100%
58-
- Volume Level on Button _(Mac only)_
63+
- Volume Level on Button (with zone colors) _(Mac only)_
5964
- Current Track Name on Button

companion-module/src/feedbacks.js

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,113 @@
11
const { combineRgb } = require('@companion-module/base')
22

3+
function parseVolumePercent(self) {
4+
const raw = self.STATUS.state && self.STATUS.state.volume
5+
const v = parseInt(raw, 10)
6+
if (Number.isNaN(v)) return null
7+
return Math.min(100, Math.max(0, v))
8+
}
9+
10+
/** Map prefixed feedback options to advanced feedback style (Companion merges multiple feedbacks). */
11+
function buildAdvancedStyleFromOptions(opt, idPrefix) {
12+
const g = (k) => opt[`${idPrefix}_${k}`]
13+
const out = {}
14+
const bg = g('bgcolor')
15+
const fg = g('color')
16+
const size = g('size')
17+
const alignment = g('alignment')
18+
const showTop = g('show_topbar')
19+
const text = g('text')
20+
if (bg !== undefined && bg !== null && bg !== '') out.bgcolor = bg
21+
if (fg !== undefined && fg !== null && fg !== '') out.color = fg
22+
if (size !== undefined && size !== null && size !== '') out.size = size
23+
if (alignment) out.alignment = alignment
24+
if (showTop !== undefined) out.show_topbar = showTop
25+
if (text !== undefined && String(text).trim() !== '') {
26+
out.text = String(text)
27+
out.textExpression = false
28+
}
29+
return out
30+
}
31+
32+
function alignmentChoices() {
33+
const positions = [
34+
['left', 'Left'],
35+
['center', 'Center'],
36+
['right', 'Right'],
37+
]
38+
const vpos = [
39+
['top', 'Top'],
40+
['center', 'Middle'],
41+
['bottom', 'Bottom'],
42+
]
43+
const choices = []
44+
for (const [h, hLabel] of positions) {
45+
for (const [v, vLabel] of vpos) {
46+
const id = `${h}:${v}`
47+
choices.push({ id, label: `${hLabel} / ${vLabel}` })
48+
}
49+
}
50+
return choices
51+
}
52+
53+
/** @param {string} idPrefix unique prefix per feedback (Companion option ids) */
54+
function volumeStyleOptionFields(idPrefix) {
55+
return [
56+
{
57+
type: 'static-text',
58+
id: `${idPrefix}_style_hdr`,
59+
label: 'Style when this feedback matches (use several feedbacks on one button for multiple zones)',
60+
},
61+
{
62+
type: 'colorpicker',
63+
id: `${idPrefix}_bgcolor`,
64+
label: 'Background color',
65+
default: combineRgb(0, 0, 0),
66+
},
67+
{
68+
type: 'colorpicker',
69+
id: `${idPrefix}_color`,
70+
label: 'Text color',
71+
default: combineRgb(255, 255, 255),
72+
},
73+
{
74+
type: 'dropdown',
75+
id: `${idPrefix}_size`,
76+
label: 'Text size',
77+
default: 'auto',
78+
choices: [
79+
{ id: 'auto', label: 'Auto' },
80+
{ id: '7', label: '7' },
81+
{ id: '14', label: '14' },
82+
{ id: '18', label: '18' },
83+
{ id: '24', label: '24' },
84+
{ id: '30', label: '30' },
85+
{ id: '44', label: '44' },
86+
],
87+
},
88+
{
89+
type: 'dropdown',
90+
id: `${idPrefix}_alignment`,
91+
label: 'Text alignment',
92+
default: 'center:center',
93+
choices: alignmentChoices(),
94+
},
95+
{
96+
type: 'checkbox',
97+
id: `${idPrefix}_show_topbar`,
98+
label: 'Show topbar',
99+
default: true,
100+
},
101+
{
102+
type: 'textinput',
103+
id: `${idPrefix}_text`,
104+
label: 'Button text (optional)',
105+
default: '',
106+
tooltip: 'Leave empty to keep the button’s existing text; expressions are not used unless you enable expression mode in Companion for this field',
107+
},
108+
]
109+
}
110+
3111
module.exports = {
4112
// ##########################
5113
// #### Define Feedbacks ####
@@ -106,6 +214,122 @@ module.exports = {
106214
},
107215
}
108216

217+
feedbacks.volumeBelow = {
218+
type: 'advanced',
219+
name: 'Volume: below',
220+
description:
221+
'When macOS system output volume is strictly below the threshold, apply the style below. Stack with “Volume: between” and “Volume: above” for multi-zone buttons.',
222+
options: [
223+
{
224+
type: 'static-text',
225+
id: 'vol_below_match_hdr',
226+
label: 'Match when volume is less than (strictly below):',
227+
},
228+
{
229+
type: 'number',
230+
id: 'threshold',
231+
label: 'Below (%)',
232+
default: 50,
233+
min: 0,
234+
max: 100,
235+
range: true,
236+
},
237+
...volumeStyleOptionFields('vb'),
238+
],
239+
callback: async (event) => {
240+
const v = parseVolumePercent(this)
241+
if (v === null) return {}
242+
let t = Number(event.options.threshold)
243+
if (Number.isNaN(t)) t = 50
244+
t = Math.min(100, Math.max(0, t))
245+
if (v >= t) return {}
246+
return buildAdvancedStyleFromOptions(event.options, 'vb')
247+
},
248+
}
249+
250+
feedbacks.volumeBetween = {
251+
type: 'advanced',
252+
name: 'Volume: between',
253+
description:
254+
'When volume is between the low and high values (inclusive on both ends), apply the style. Stack multiple instances for different bands.',
255+
options: [
256+
{
257+
type: 'static-text',
258+
id: 'vol_between_match_hdr',
259+
label: 'Match when volume is in this range (%), inclusive:',
260+
},
261+
{
262+
type: 'number',
263+
id: 'low',
264+
label: 'Low (%)',
265+
default: 50,
266+
min: 0,
267+
max: 100,
268+
range: true,
269+
},
270+
{
271+
type: 'number',
272+
id: 'high',
273+
label: 'High (%)',
274+
default: 75,
275+
min: 0,
276+
max: 100,
277+
range: true,
278+
},
279+
...volumeStyleOptionFields('vbt'),
280+
],
281+
callback: async (event) => {
282+
const v = parseVolumePercent(this)
283+
if (v === null) return {}
284+
let low = Number(event.options.low)
285+
let high = Number(event.options.high)
286+
if (Number.isNaN(low)) low = 50
287+
if (Number.isNaN(high)) high = 75
288+
low = Math.min(100, Math.max(0, low))
289+
high = Math.min(100, Math.max(0, high))
290+
if (low > high) {
291+
const x = low
292+
low = high
293+
high = x
294+
}
295+
if (v < low || v > high) return {}
296+
return buildAdvancedStyleFromOptions(event.options, 'vbt')
297+
},
298+
}
299+
300+
feedbacks.volumeAbove = {
301+
type: 'advanced',
302+
name: 'Volume: above',
303+
description:
304+
'When volume is strictly greater than the threshold, apply the style. Stack with “Volume: below” and “Volume: between”.',
305+
options: [
306+
{
307+
type: 'static-text',
308+
id: 'vol_above_match_hdr',
309+
label: 'Match when volume is greater than (strictly above):',
310+
},
311+
{
312+
type: 'number',
313+
id: 'threshold',
314+
label: 'Above (%)',
315+
default: 75,
316+
min: 0,
317+
max: 100,
318+
range: true,
319+
},
320+
...volumeStyleOptionFields('va'),
321+
],
322+
callback: async (event) => {
323+
const v = parseVolumePercent(this)
324+
if (v === null) return {}
325+
let t = Number(event.options.threshold)
326+
if (Number.isNaN(t)) t = 75
327+
t = Math.min(100, Math.max(0, t))
328+
if (v <= t) return {}
329+
return buildAdvancedStyleFromOptions(event.options, 'va')
330+
},
331+
}
332+
109333
this.setFeedbackDefinitions(feedbacks)
110334
},
111335
}

companion-module/src/presets.js

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ module.exports = {
201201
category: 'Volume',
202202
name: 'Volume Level',
203203
style: {
204-
text: 'VOL:\\n$(spotify-controller:volume)',
204+
text: 'VOL:\\n$(spotify-controller:volume_percent)',
205205
size: '18',
206206
color: combineRgb(255, 255, 255),
207207
bgcolor: combineRgb(0, 0, 0),
@@ -212,7 +212,45 @@ module.exports = {
212212
up: [],
213213
},
214214
],
215-
feedbacks: [],
215+
feedbacks: [
216+
{
217+
feedbackId: 'volumeBelow',
218+
options: {
219+
threshold: 50,
220+
vb_bgcolor: combineRgb(22, 163, 74),
221+
vb_color: combineRgb(255, 255, 255),
222+
vb_size: '18',
223+
vb_alignment: 'center:center',
224+
vb_show_topbar: true,
225+
vb_text: '',
226+
},
227+
},
228+
{
229+
feedbackId: 'volumeBetween',
230+
options: {
231+
low: 50,
232+
high: 75,
233+
vbt_bgcolor: combineRgb(234, 88, 12),
234+
vbt_color: combineRgb(255, 255, 255),
235+
vbt_size: '18',
236+
vbt_alignment: 'center:center',
237+
vbt_show_topbar: true,
238+
vbt_text: '',
239+
},
240+
},
241+
{
242+
feedbackId: 'volumeAbove',
243+
options: {
244+
threshold: 75,
245+
va_bgcolor: combineRgb(220, 38, 38),
246+
va_color: combineRgb(255, 255, 255),
247+
va_size: '18',
248+
va_alignment: 'center:center',
249+
va_show_topbar: true,
250+
va_text: '',
251+
},
252+
},
253+
],
216254
})
217255

218256
presets.push({

0 commit comments

Comments
 (0)