Fix sound options and panel controls
This commit is contained in:
+11
-3
@@ -85,13 +85,21 @@ var EFFECTS = [
|
||||
},
|
||||
{
|
||||
type: "sound", label: "Sound", icon: "",
|
||||
subtitle: "Plays a chime",
|
||||
subtitle: "Plays a selected chime; Fit in 0 uses its natural speed",
|
||||
rows: [
|
||||
{ key: "duration", label: "Fit in", min: 0, max: 30, step: 0.1, fallback: 0, unit: " s" },
|
||||
{ key: "intensity", label: "Volume", min: 0, max: 2, step: 0.01, fallback: 1, unit: "" },
|
||||
{ key: "speed", label: "Repeat", min: 1, max: 50, step: 1, fallback: 1, unit: " ×" }
|
||||
{ key: "repeat", label: "Times", min: 1, max: 50, step: 1, fallback: 1, unit: " ×" }
|
||||
],
|
||||
options: [
|
||||
{ key: "file", label: "File", type: "text", fallback: "", placeholder: "empty: the default chime · or a path to a sound file" }
|
||||
{ key: "sound", label: "Sound", type: "enum", fallback: "message",
|
||||
values: [
|
||||
{ value: "message", label: "Message" },
|
||||
{ value: "bell", label: "Bell" },
|
||||
{ value: "warning", label: "Warning" },
|
||||
{ value: "complete", label: "Complete" },
|
||||
{ value: "phone", label: "Phone" }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,8 @@ or resume every effect. A first rule is there to start: any notification that
|
||||
mentions **deliveroo** shakes the screen.
|
||||
|
||||
Runtime dependencies, all part of a stock Omarchy: `jq`, `busctl` (systemd),
|
||||
`hyprctl`, `notify-send`, `pw-play` (for the sound). `./install.sh` is an
|
||||
`hyprctl`, `notify-send`, `pw-play` (for the sound), and `ffmpeg`/`ffprobe`
|
||||
(when sound repeats must fit a duration). `./install.sh` is an
|
||||
optional helper for a checkout somewhere else: it links the plugin into
|
||||
`~/.config/omarchy/plugins`, puts the `attention-required` command on your
|
||||
PATH and enables the bell. `omarchy plugin add` never runs it.
|
||||
@@ -87,7 +88,7 @@ that does not set the option itself.
|
||||
| `airplane` | A plane bobs across the screen, trailing exhaust, towing the message on a rippling flag. | flight seconds (7) | size (1) | | `altitude` 0..1 (0.2), `direction`: ltr, rtl; `text` |
|
||||
| `confetti` | Confetti in the theme's colours. | seconds (1) | amount (1) | launch power (1) | `style`: cannons (bottom corners, up), burst (centre), rain (top) |
|
||||
| `blink` | The screen dims and comes back. | seconds (1) | darkness 0..1 (0.6) | blinks per second (2) | |
|
||||
| `sound` | Plays a chime with `pw-play`. | | volume (1) | times (1) | `file` |
|
||||
| `sound` | Plays a selected chime with `pw-play`. | seconds to fit repeats; 0 is natural speed (0) | volume (1) | times (1) | Message, bell, warning, complete, or phone |
|
||||
| `focus` | Brings the sending app's window to the front. | | | | `window`: a class or title instead |
|
||||
| `command` | Runs a shell command. | | | | `run`; sees `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, every option as `AR_OPT_<NAME>` |
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ Values outside a range are clamped when the effect runs. Every effect has
|
||||
| `airplane` | flight seconds 0.5..120 (7) | size 0.2..10 (1) | | `altitude` 0..1 from the top (0.2); `direction`: `ltr`, `rtl`; `text` template |
|
||||
| `confetti` | seconds it keeps coming 0.1..60 (1) | amount 0.05..20 (1) | launch power 0.1..10 (1) | `style`: `cannons` (bottom corners, up), `burst` (centre), `rain` (top) |
|
||||
| `blink` | seconds 0.1..30 (1) | darkness 0.01..1 (0.6) | blinks per second 0.2..30 (2) | |
|
||||
| `sound` | | volume 0..2 (1) | times played 1..50 (1) | `file`: path to a sound file (default: freedesktop's new-message chime) |
|
||||
| `sound` | seconds to fit repeats 0..30; 0 is natural speed (0) | volume 0..2 (1) | repeat 1..50 (1) | `sound`: `message`, `bell`, `warning`, `complete`, or `phone` |
|
||||
| `focus` | | | | `window`: a window class or title to focus instead of the sending app |
|
||||
| `command` | | | | `run`: a shell command, run as the user with `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, `AR_KEY`, `AR_URGENCY` and every effect option as `AR_OPT_<NAME>` in the environment |
|
||||
|
||||
|
||||
+35
-10
@@ -25,7 +25,7 @@ Panel {
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
readonly property var svc: bar && bar.shell ? bar.shell.serviceFor("alanfortlink.attention-required") : null
|
||||
property var svc: null
|
||||
readonly property bool paused: svc ? !svc.enabled : false
|
||||
readonly property var cfg: svc ? svc.rawConfig : ({})
|
||||
readonly property var rules: svc ? svc.rawRules : []
|
||||
@@ -40,6 +40,25 @@ Panel {
|
||||
readonly property int labelW: Style.space(96)
|
||||
readonly property var catalog: Catalog.EFFECTS
|
||||
|
||||
function bindService() {
|
||||
var host = bar && bar.shell ? bar.shell : null
|
||||
if (!host || typeof host.serviceFor !== "function") return
|
||||
var service = host.serviceFor("alanfortlink.attention-required")
|
||||
if (service) root.svc = service
|
||||
}
|
||||
|
||||
onBarChanged: {
|
||||
root.svc = null
|
||||
root.bindService()
|
||||
}
|
||||
Component.onCompleted: root.bindService()
|
||||
Timer {
|
||||
interval: 200
|
||||
running: root.svc === null
|
||||
repeat: true
|
||||
onTriggered: root.bindService()
|
||||
}
|
||||
|
||||
// ---------- navigation ----------
|
||||
property string page: "list" // list | rule | effect
|
||||
property int current: -1 // the rule the deeper pages are about
|
||||
@@ -185,27 +204,33 @@ Panel {
|
||||
spacing: Style.space(10)
|
||||
|
||||
PanelHero {
|
||||
id: hero
|
||||
width: parent.width
|
||||
title: "Attention Required"
|
||||
meta: root.armedLabel()
|
||||
detail: root.svc && root.svc.configError !== "" ? root.svc.configError : ""
|
||||
detail: !root.svc ? "Service unavailable" : (root.svc.configError !== "" ? root.svc.configError : "")
|
||||
foreground: root.fg
|
||||
fontFamily: root.fontFamily
|
||||
readonly property bool attentionPaused: root.paused
|
||||
readonly property var attentionService: root.svc
|
||||
function toggleAttention() {
|
||||
if (attentionService) attentionService.setEnabled(attentionPaused)
|
||||
}
|
||||
iconComponent: Component {
|
||||
Text {
|
||||
text: root.paused ? "" : ""
|
||||
color: root.fg
|
||||
opacity: root.paused ? 0.5 : 1
|
||||
font.family: root.fontFamily
|
||||
text: hero.attentionPaused ? "" : ""
|
||||
color: hero.foreground
|
||||
opacity: hero.attentionPaused ? 0.5 : 1
|
||||
font.family: hero.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
}
|
||||
}
|
||||
trailingControl: Component {
|
||||
ToggleSwitch {
|
||||
checked: !root.paused
|
||||
foreground: root.fg
|
||||
cursorPad: root.trailInset
|
||||
onToggled: if (root.svc) root.svc.setEnabled(root.paused)
|
||||
checked: !hero.attentionPaused
|
||||
foreground: hero.foreground
|
||||
cursorPad: Style.space(6)
|
||||
onToggled: hero.toggleAttention()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
+55
-5
@@ -3,14 +3,23 @@
|
||||
# Plays a sound.
|
||||
#
|
||||
# Options (set on the effect in rules.json):
|
||||
# file path to a sound file; default is the freedesktop "new message" chime
|
||||
# sound message | bell | warning | complete | phone (default: message)
|
||||
# duration fit all repeats into this many seconds; 0 uses natural speed
|
||||
# intensity volume, 0.0 .. 1.0, default 1 (`volume` works too)
|
||||
# speed how many times to play it, default 1 (`repeat` works too)
|
||||
# repeat how many times to play it, default 1 (`speed` works for old rules)
|
||||
set -uo pipefail
|
||||
|
||||
file=${AR_OPT_FILE:-/usr/share/sounds/freedesktop/stereo/message-new-instant.oga}
|
||||
case ${AR_OPT_SOUND:-message} in
|
||||
bell) file=/usr/share/sounds/freedesktop/stereo/bell.oga ;;
|
||||
warning) file=/usr/share/sounds/freedesktop/stereo/dialog-warning.oga ;;
|
||||
complete) file=/usr/share/sounds/freedesktop/stereo/complete.oga ;;
|
||||
phone) file=/usr/share/sounds/freedesktop/stereo/phone-incoming-call.oga ;;
|
||||
*) file=/usr/share/sounds/freedesktop/stereo/message-new-instant.oga ;;
|
||||
esac
|
||||
volume=${AR_OPT_INTENSITY:-${AR_OPT_VOLUME:-1}}
|
||||
times=${AR_OPT_SPEED:-${AR_OPT_REPEAT:-1}}
|
||||
times=${AR_OPT_REPEAT:-}
|
||||
[[ $times =~ ^[0-9]+$ ]] || times=${AR_OPT_SPEED:-1}
|
||||
duration=${AR_OPT_DURATION:-0}
|
||||
[[ $volume =~ ^[0-9]*\.?[0-9]+$ ]] || volume=1
|
||||
[[ $times =~ ^[0-9]+$ ]] || times=1
|
||||
(( times > 50 )) && times=50
|
||||
@@ -31,6 +40,47 @@ play() {
|
||||
fi
|
||||
}
|
||||
|
||||
play_repeatedly() {
|
||||
for ((i = 0; i < times; i++)); do
|
||||
play || exit 1
|
||||
play || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# ffmpeg changes the tempo of each chime so all repeats fit the chosen duration.
|
||||
# Each repetition gets its own playback stream, keeping every hit distinct.
|
||||
fit_duration() {
|
||||
command -v ffprobe >/dev/null 2>&1 || { echo "sound: ffprobe is required when Fit in is set" >&2; return 1; }
|
||||
command -v ffmpeg >/dev/null 2>&1 || { echo "sound: ffmpeg is required when Fit in is set" >&2; return 1; }
|
||||
command -v pw-play >/dev/null 2>&1 || { echo "sound: pw-play is required when Fit in is set" >&2; return 1; }
|
||||
|
||||
local clip total tempo filter
|
||||
clip=$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$file" 2>/dev/null) || return 1
|
||||
[[ $clip =~ ^[0-9]*\.?[0-9]+$ ]] || return 1
|
||||
total=$(awk -v c="$clip" -v n="$times" 'BEGIN { printf "%.8f", c * n }')
|
||||
tempo=$(awk -v t="$total" -v d="$duration" 'BEGIN { printf "%.8f", t / d }')
|
||||
[[ $tempo =~ ^[0-9]*\.?[0-9]+$ ]] || return 1
|
||||
|
||||
# atempo accepts one factor from 0.5 to 2. Chain factors for every value the
|
||||
# settings slider permits, preserving pitch while changing playback speed.
|
||||
filter=
|
||||
while awk -v v="$tempo" 'BEGIN { exit !(v > 2) }'; do
|
||||
filter+="atempo=2,"
|
||||
tempo=$(awk -v v="$tempo" 'BEGIN { printf "%.8f", v / 2 }')
|
||||
done
|
||||
while awk -v v="$tempo" 'BEGIN { exit !(v < 0.5) }'; do
|
||||
filter+="atempo=0.5,"
|
||||
tempo=$(awk -v v="$tempo" 'BEGIN { printf "%.8f", v * 2 }')
|
||||
done
|
||||
filter+="atempo=$tempo"
|
||||
|
||||
for ((i = 0; i < times; i++)); do
|
||||
ffmpeg -nostdin -v error -i "$file" -filter:a "$filter" -f wav - \
|
||||
| pw-play --volume="$volume" - || return 1
|
||||
done
|
||||
}
|
||||
|
||||
if [[ $duration =~ ^[0-9]*\.?[0-9]+$ ]] && awk -v d="$duration" 'BEGIN { exit !(d > 0) }'; then
|
||||
fit_duration || exit $?
|
||||
else
|
||||
play_repeatedly
|
||||
fi
|
||||
|
||||
@@ -6,6 +6,10 @@ const assert = require("assert")
|
||||
const src = fs.readFileSync(path.join(__dirname, "..", "Rules.js"), "utf8").replace(/\/\/ Not a .pragma library[^\n]*/, "")
|
||||
const R = {}
|
||||
new Function("exports", src + "\n" + ["normalizeConfig", "matchingRules", "stripTags", "describeRule", "normalizeEffect", "withDefaults", "renderTemplate"].map(n => `exports.${n} = ${n}`).join("\n"))(R)
|
||||
const catalogSrc = fs.readFileSync(path.join(__dirname, "..", "EffectCatalog.js"), "utf8")
|
||||
const C = {}
|
||||
new Function("exports", catalogSrc + "\nexports.effects = EFFECTS")(C)
|
||||
const settingsSrc = fs.readFileSync(path.join(__dirname, "..", "Settings.qml"), "utf8")
|
||||
|
||||
const cfg = R.normalizeConfig({
|
||||
rules: [
|
||||
@@ -58,4 +62,13 @@ assert.deepStrictEqual(all.rules[1].effects, [])
|
||||
assert.strictEqual(R.renderTemplate("", { summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "Hi")
|
||||
assert.strictEqual(R.renderTemplate("{rule}: {summary} / {body} ({app})", { app: "A", summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "r: Hi / x (A)")
|
||||
|
||||
const sound = C.effects.find(effect => effect.type === "sound")
|
||||
assert.ok(sound.rows.some(row => row.key === "duration" && row.min === 0 && row.max === 30 && row.fallback === 0))
|
||||
assert.ok(sound.rows.some(row => row.key === "repeat" && row.min === 1 && row.max === 50))
|
||||
assert.strictEqual(sound.options.length, 1)
|
||||
assert.deepStrictEqual(sound.options[0].values.map(value => value.value), ["message", "bell", "warning", "complete", "phone"])
|
||||
assert.ok(settingsSrc.includes("function bindService()"))
|
||||
assert.ok(settingsSrc.includes("running: root.svc === null"))
|
||||
assert.ok(settingsSrc.includes("onToggled: hero.toggleAttention()"))
|
||||
|
||||
console.log("rules: all tests passed")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verifies that the repeat setting reaches the player, including when a saved
|
||||
# legacy speed value is present alongside it.
|
||||
set -euo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "$0")/.." && pwd)
|
||||
log=$(mktemp)
|
||||
trap 'rm -f -- "$log"' EXIT
|
||||
|
||||
pw-play() { printf '%s\n' "$*" >> "$AR_SOUND_LOG"; }
|
||||
export -f pw-play
|
||||
|
||||
# Every UI choice must reach a different system sound through ar-effect.
|
||||
sounds=(message bell warning complete phone)
|
||||
files=(message-new-instant.oga bell.oga dialog-warning.oga complete.oga phone-incoming-call.oga)
|
||||
for i in "${!sounds[@]}"; do
|
||||
: > "$log"
|
||||
payload="{\"effect\":{\"type\":\"sound\",\"sound\":\"${sounds[i]}\",\"repeat\":1,\"duration\":0},\"notification\":{},\"rule\":{\"name\":\"test\"}}"
|
||||
AR_SOUND_LOG="$log" bash "$repo/bin/ar-effect" sound "$payload"
|
||||
grep -Fqx -- "--volume=1 /usr/share/sounds/freedesktop/stereo/${files[i]}" "$log"
|
||||
done
|
||||
|
||||
# An explicit repeat count wins over a saved legacy speed value.
|
||||
: > "$log"
|
||||
payload='{"effect":{"type":"sound","sound":"phone","repeat":3,"speed":1,"duration":0},"notification":{},"rule":{"name":"test"}}'
|
||||
AR_SOUND_LOG="$log" bash "$repo/bin/ar-effect" sound "$payload"
|
||||
|
||||
[[ $(wc -l < "$log") -eq 3 ]]
|
||||
[[ $(grep -Fxc -- "--volume=1 /usr/share/sounds/freedesktop/stereo/phone-incoming-call.oga" "$log") -eq 3 ]]
|
||||
|
||||
# Each repeat is separately tempo-adjusted when a duration is selected.
|
||||
ffprobe() { printf '1\n'; }
|
||||
ffmpeg() {
|
||||
printf 'ffmpeg %s\n' "$*" >> "$AR_SOUND_LOG"
|
||||
printf 'wav'
|
||||
}
|
||||
pw-play() {
|
||||
cat >/dev/null
|
||||
printf 'pw-play %s\n' "$*" >> "$AR_SOUND_LOG"
|
||||
}
|
||||
export -f ffprobe ffmpeg pw-play
|
||||
: > "$log"
|
||||
AR_OPT_SOUND=bell \
|
||||
AR_OPT_DURATION=1 \
|
||||
AR_OPT_REPEAT=3 \
|
||||
AR_SOUND_LOG="$log" \
|
||||
bash "$repo/effects/sound"
|
||||
|
||||
[[ $(grep -Fc -- 'ffmpeg -nostdin -v error -i /usr/share/sounds/freedesktop/stereo/bell.oga -filter:a atempo=2,atempo=1.50000000 -f wav -' "$log") -eq 3 ]]
|
||||
[[ $(grep -Fxc -- 'pw-play --volume=1 -' "$log") -eq 3 ]]
|
||||
|
||||
# A longer target duration slows each repetition by the corresponding factor.
|
||||
: > "$log"
|
||||
AR_OPT_SOUND=warning \
|
||||
AR_OPT_DURATION=4 \
|
||||
AR_OPT_REPEAT=1 \
|
||||
AR_SOUND_LOG="$log" \
|
||||
bash "$repo/effects/sound"
|
||||
|
||||
grep -Fq -- '-filter:a atempo=0.5,atempo=0.50000000 -f wav -' "$log"
|
||||
grep -Fqx -- 'pw-play --volume=1 -' "$log"
|
||||
|
||||
echo "sound: all tests passed"
|
||||
Reference in New Issue
Block a user