feat: add mpv scripts
This commit is contained in:
263
scripts/ac-power.lua
Normal file
263
scripts/ac-power.lua
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
--[[
|
||||||
|
ac-power.lua - displays Home Assistant AC power usage in the mpv HUD.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* Polls `/api/states/<sensor_id>` via curl with a long-lived token and trims the base URL to avoid duplicate slashes.
|
||||||
|
* Debounces HUD spam by only re-rendering when watt deltas exceed `min_delta_watts` or when `force_refresh_interval` elapses.
|
||||||
|
* Survives flaky networks with capped exponential backoff, optional HUD error messages, and redraws of the last known value after OSD clears.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
Edit the CONFIG table below to match your Home Assistant deployment; the script ignores script-opts files.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local mp = require("mp")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
local msg = require("mp.msg")
|
||||||
|
|
||||||
|
local CONFIG = {
|
||||||
|
sensor_id = "sensor.ac_power", -- Home Assistant entity id
|
||||||
|
ha_base_url = "http://homeassistant.local:8123",
|
||||||
|
ha_token = "REPLACE_WITH_LONG_LIVED_TOKEN",
|
||||||
|
poll_interval = 3, -- seconds between successful polls
|
||||||
|
min_delta_watts = 5, -- only show when change ≥ delta
|
||||||
|
force_refresh_interval = 30, -- seconds, even if delta small
|
||||||
|
display_label = "AC Power",
|
||||||
|
units_label = "W",
|
||||||
|
use_sensor_unit = true, -- prefer HA provided unit if any
|
||||||
|
display_timeout = 3, -- seconds OSD message stays up
|
||||||
|
decimal_places = 0, -- number formatting precision
|
||||||
|
http_timeout = 2, -- curl timeout in seconds
|
||||||
|
curl_binary = "curl", -- binary used for HTTP calls
|
||||||
|
insecure_ssl = false, -- set true to allow self-signed
|
||||||
|
max_backoff = 30, -- cap between retries when failing
|
||||||
|
show_errors = true, -- display API errors via HUD
|
||||||
|
initial_message = "Fetching AC power…", -- shown until first reading
|
||||||
|
initial_message_duration = 0, -- 0 keeps OSD up until refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
local last_watts
|
||||||
|
local last_display_ts = 0
|
||||||
|
local last_unit
|
||||||
|
local request_inflight = false
|
||||||
|
local backoff_failures = 0
|
||||||
|
local poll_timer
|
||||||
|
|
||||||
|
local function log(level, text)
|
||||||
|
msg[level]("ac-power: " .. text)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim_trailing_slash(url)
|
||||||
|
if url:sub(-1) == "/" then
|
||||||
|
return url:sub(1, -2)
|
||||||
|
end
|
||||||
|
return url
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_url()
|
||||||
|
return string.format(
|
||||||
|
"%s/api/states/%s",
|
||||||
|
trim_trailing_slash(CONFIG.ha_base_url),
|
||||||
|
CONFIG.sensor_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_request()
|
||||||
|
local args = { CONFIG.curl_binary, "-sS", "-m", tostring(CONFIG.http_timeout) }
|
||||||
|
if CONFIG.insecure_ssl then
|
||||||
|
table.insert(args, "-k")
|
||||||
|
end
|
||||||
|
table.insert(args, "-H")
|
||||||
|
table.insert(args, "Authorization: Bearer " .. CONFIG.ha_token)
|
||||||
|
table.insert(args, build_url())
|
||||||
|
return {
|
||||||
|
name = "subprocess",
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
args = args,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function format_value(value)
|
||||||
|
local fmt = "%0." .. tostring(CONFIG.decimal_places) .. "f"
|
||||||
|
return string.format(fmt, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sensor_units(payload)
|
||||||
|
if CONFIG.use_sensor_unit and payload and payload.attributes then
|
||||||
|
local unit = payload.attributes.unit_of_measurement
|
||||||
|
if unit and unit ~= "" then
|
||||||
|
return unit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return CONFIG.units_label
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_message(text, duration)
|
||||||
|
local timeout = duration
|
||||||
|
if timeout == nil then
|
||||||
|
timeout = CONFIG.display_timeout
|
||||||
|
end
|
||||||
|
mp.osd_message(text, timeout)
|
||||||
|
last_display_ts = mp.get_time()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_value(value_text, unit)
|
||||||
|
show_message(string.format("%s: %s %s", CONFIG.display_label, value_text, unit or ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_error(err)
|
||||||
|
if CONFIG.show_errors then
|
||||||
|
show_message(string.format("%s: -- (%s)", CONFIG.display_label, err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_poll_timer(delay, fn)
|
||||||
|
if poll_timer then
|
||||||
|
poll_timer:kill()
|
||||||
|
end
|
||||||
|
poll_timer = mp.add_timeout(delay, function()
|
||||||
|
poll_timer = nil
|
||||||
|
fn()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_success(value, payload)
|
||||||
|
backoff_failures = 0
|
||||||
|
local now = mp.get_time()
|
||||||
|
local printable = value and format_value(value) or "--"
|
||||||
|
local unit = sensor_units(payload)
|
||||||
|
local force_due = CONFIG.force_refresh_interval > 0
|
||||||
|
and (now - last_display_ts) >= CONFIG.force_refresh_interval
|
||||||
|
local significant_change = value
|
||||||
|
and (not last_watts or math.abs(value - last_watts) >= CONFIG.min_delta_watts)
|
||||||
|
|
||||||
|
if significant_change or force_due or last_watts == nil then
|
||||||
|
show_value(printable, unit)
|
||||||
|
last_unit = unit
|
||||||
|
last_watts = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function redraw_last_value()
|
||||||
|
if last_watts then
|
||||||
|
show_value(format_value(last_watts), last_unit)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if CONFIG.initial_message and CONFIG.initial_message ~= "" then
|
||||||
|
show_message(
|
||||||
|
string.format("%s: %s", CONFIG.display_label, CONFIG.initial_message),
|
||||||
|
CONFIG.initial_message_duration
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function poll_sensor()
|
||||||
|
if request_inflight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
request_inflight = true
|
||||||
|
|
||||||
|
mp.command_native_async(build_request(), function(success, result, err)
|
||||||
|
request_inflight = false
|
||||||
|
|
||||||
|
if not success or not result then
|
||||||
|
backoff_failures = backoff_failures + 1
|
||||||
|
local message = err or "request failed"
|
||||||
|
log("error", message)
|
||||||
|
show_error(message)
|
||||||
|
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||||
|
ensure_poll_timer(next_delay, poll_sensor)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if result.status ~= 0 then
|
||||||
|
backoff_failures = backoff_failures + 1
|
||||||
|
local stderr = (result.stderr and result.stderr ~= "" and result.stderr)
|
||||||
|
or ("curl exit " .. tostring(result.status))
|
||||||
|
log("error", stderr)
|
||||||
|
show_error(stderr)
|
||||||
|
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||||
|
ensure_poll_timer(next_delay, poll_sensor)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not result.stdout or result.stdout == "" then
|
||||||
|
backoff_failures = backoff_failures + 1
|
||||||
|
log("error", "empty response from Home Assistant")
|
||||||
|
show_error("empty response")
|
||||||
|
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||||
|
ensure_poll_timer(next_delay, poll_sensor)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local payload = utils.parse_json(result.stdout)
|
||||||
|
if not payload then
|
||||||
|
backoff_failures = backoff_failures + 1
|
||||||
|
log("error", "invalid JSON payload")
|
||||||
|
show_error("invalid JSON")
|
||||||
|
local next_delay = math.min(CONFIG.max_backoff, CONFIG.poll_interval * (2 ^ backoff_failures))
|
||||||
|
ensure_poll_timer(next_delay, poll_sensor)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local numeric_value = tonumber(payload.state)
|
||||||
|
if not numeric_value then
|
||||||
|
log("warn", "sensor state not numeric: " .. tostring(payload.state))
|
||||||
|
show_error("state unknown")
|
||||||
|
else
|
||||||
|
handle_success(numeric_value, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
backoff_failures = 0
|
||||||
|
ensure_poll_timer(CONFIG.poll_interval, poll_sensor)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validate_config()
|
||||||
|
if CONFIG.ha_base_url == "" then
|
||||||
|
log("error", "ha_base_url is required")
|
||||||
|
show_error("missing base url")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if CONFIG.sensor_id == "" then
|
||||||
|
log("error", "sensor_id is required")
|
||||||
|
show_error("missing sensor id")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if CONFIG.ha_token == "" or CONFIG.ha_token == "REPLACE_WITH_LONG_LIVED_TOKEN" then
|
||||||
|
log("error", "ha_token must be set to a Home Assistant long-lived token")
|
||||||
|
show_error("missing HA token")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start()
|
||||||
|
if not validate_config() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if CONFIG.initial_message and CONFIG.initial_message ~= "" then
|
||||||
|
show_message(
|
||||||
|
string.format("%s: %s", CONFIG.display_label, CONFIG.initial_message),
|
||||||
|
CONFIG.initial_message_duration
|
||||||
|
)
|
||||||
|
end
|
||||||
|
ensure_poll_timer(0.1, poll_sensor)
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.register_script_message("ac-power-refresh", function()
|
||||||
|
log("info", "manual refresh requested")
|
||||||
|
ensure_poll_timer(0.01, poll_sensor)
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
if poll_timer then
|
||||||
|
poll_timer:kill()
|
||||||
|
poll_timer = nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.register_event("file-loaded", redraw_last_value)
|
||||||
|
mp.register_event("playback-restart", redraw_last_value)
|
||||||
|
|
||||||
|
start()
|
||||||
440
scripts/kb-blackout.lua
Normal file
440
scripts/kb-blackout.lua
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
--[[
|
||||||
|
script-name=kb-blackout
|
||||||
|
script-description=Dims (or disables) the keyboard backlight while mpv plays media.
|
||||||
|
|
||||||
|
Behaviors:
|
||||||
|
* Observes pause/playback/core-idle properties to decide when to dim or restore, cancelling pending timers once playback stops.
|
||||||
|
* Stores the original brightness level per session and uses `brightnessctl` exclusively for both detection and control so permissions stay centralized.
|
||||||
|
* Debounces rapid toggles, auto-detects LED devices when possible, and warns when Linux platform, device path, or privileges do not satisfy the requirements.
|
||||||
|
|
||||||
|
Configuration (edit values here):
|
||||||
|
timeout_ms = 3000 -- delay after playback resumes before lights turn off
|
||||||
|
restore_on_pause = true -- immediately restore while paused
|
||||||
|
minimum_brightness = 0 -- fallback value if hardware rejects zero (use integer >= 0)
|
||||||
|
led_path = "" -- optional brightnessctl device override (see `brightnessctl --list`)
|
||||||
|
debounce_ms = 250 -- suppress duplicate intents within this window
|
||||||
|
brightnessctl_path = "brightnessctl" -- path to brightnessctl binary
|
||||||
|
]]
|
||||||
|
|
||||||
|
local mp = require("mp")
|
||||||
|
local msg = require("mp.msg")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
local jit = rawget(_G, "jit")
|
||||||
|
|
||||||
|
local config = {
|
||||||
|
timeout_ms = 3000,
|
||||||
|
restore_on_pause = true,
|
||||||
|
minimum_brightness = 0,
|
||||||
|
led_path = "",
|
||||||
|
debounce_ms = 250,
|
||||||
|
brightnessctl_path = "brightnessctl",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function coerce_boolean(value, default)
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return value
|
||||||
|
elseif type(value) == "string" then
|
||||||
|
local lowered = value:lower()
|
||||||
|
if lowered == "true" or lowered == "yes" or lowered == "1" then
|
||||||
|
return true
|
||||||
|
elseif lowered == "false" or lowered == "no" or lowered == "0" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
elseif type(value) == "number" then
|
||||||
|
return value ~= 0
|
||||||
|
end
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
config.timeout_ms = tonumber(config.timeout_ms) or 3000
|
||||||
|
config.minimum_brightness = math.max(0, math.floor(tonumber(config.minimum_brightness) or 0))
|
||||||
|
config.debounce_ms = math.max(0, tonumber(config.debounce_ms) or 250)
|
||||||
|
config.restore_on_pause = coerce_boolean(config.restore_on_pause, true)
|
||||||
|
|
||||||
|
local function seconds(ms)
|
||||||
|
return (tonumber(ms) or 0) / 1000
|
||||||
|
end
|
||||||
|
|
||||||
|
local state = {
|
||||||
|
timer = nil,
|
||||||
|
playback_active = false,
|
||||||
|
is_dark = false,
|
||||||
|
original_brightness = nil,
|
||||||
|
last_intent = nil,
|
||||||
|
last_intent_ts = 0,
|
||||||
|
control_blocked = false,
|
||||||
|
filename = nil,
|
||||||
|
verified = false,
|
||||||
|
device_detection_attempted = false,
|
||||||
|
first_frame_seen = false,
|
||||||
|
pending_timer = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function detect_keyboard_device()
|
||||||
|
if config.led_path ~= "" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if state.device_detection_attempted then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.device_detection_attempted = true
|
||||||
|
|
||||||
|
local result = utils.subprocess({
|
||||||
|
args = { config.brightnessctl_path, "--list" },
|
||||||
|
cancellable = false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result or result.error or result.status ~= 0 then
|
||||||
|
msg.warn(
|
||||||
|
"Unable to auto-detect keyboard backlight (brightnessctl --list failed); set led_path manually."
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local output = result.stdout or ""
|
||||||
|
local fallback
|
||||||
|
for line in output:gmatch("[^\r\n]+") do
|
||||||
|
local name, class_name = line:match("Device '([^']+)' of class '([^']+)'")
|
||||||
|
if name and class_name and class_name:lower() == "leds" then
|
||||||
|
local lowered = name:lower()
|
||||||
|
if
|
||||||
|
lowered:find("kbd", 1, true)
|
||||||
|
or lowered:find("keyboard", 1, true)
|
||||||
|
or lowered:find("illum", 1, true)
|
||||||
|
then
|
||||||
|
config.led_path = name
|
||||||
|
msg.info(("Auto-selected keyboard LED device '%s'."):format(name))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
fallback = fallback or name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if fallback then
|
||||||
|
config.led_path = fallback
|
||||||
|
msg.info(("Auto-selected LED device '%s' (first LEDs class)."):format(fallback))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
msg.warn("No LEDs-class devices found via brightnessctl --list; set led_path manually.")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unsupported_platform()
|
||||||
|
if not jit or jit.os ~= "Linux" then
|
||||||
|
msg.warn("kb-blackout only supports Linux; disabling.")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if unsupported_platform() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local function trim(str)
|
||||||
|
return (str and str:gsub("^%s+", ""):gsub("%s+$", "")) or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_brightnessctl_args(extra)
|
||||||
|
local args = { config.brightnessctl_path }
|
||||||
|
if config.led_path ~= nil and config.led_path ~= "" then
|
||||||
|
table.insert(args, "--device")
|
||||||
|
table.insert(args, config.led_path)
|
||||||
|
end
|
||||||
|
for _, value in ipairs(extra) do
|
||||||
|
table.insert(args, value)
|
||||||
|
end
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function log_permission_hint(stderr_text)
|
||||||
|
if not stderr_text or stderr_text == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if stderr_text:match("Permission denied") then
|
||||||
|
msg.error(
|
||||||
|
"brightnessctl permission denied. Run `man brightnessctl` for guidance on udev/group access."
|
||||||
|
)
|
||||||
|
elseif stderr_text:match("No such device") or stderr_text:match("Invalid device") then
|
||||||
|
if config.led_path ~= "" then
|
||||||
|
msg.error(
|
||||||
|
("LED path '%s' not found. Run `brightnessctl --list` to discover valid names."):format(
|
||||||
|
config.led_path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
msg.error(
|
||||||
|
"Keyboard backlight LED path was not found. Consider setting `led_path` (see `brightnessctl --list`)."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_brightnessctl(extra, action)
|
||||||
|
if state.control_blocked then
|
||||||
|
return nil, "control blocked"
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = utils.subprocess({
|
||||||
|
args = build_brightnessctl_args(extra),
|
||||||
|
cancellable = false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result or result.error then
|
||||||
|
local err = result and result.error or "unknown error"
|
||||||
|
msg.error(("brightnessctl %s failed to start: %s"):format(action, err))
|
||||||
|
state.control_blocked = true
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
if result.status ~= 0 then
|
||||||
|
local stderr_text = trim(result.stderr or "")
|
||||||
|
msg.error(("brightnessctl %s failed (exit %d): %s"):format(action, result.status, stderr_text))
|
||||||
|
log_permission_hint(stderr_text)
|
||||||
|
state.control_blocked = true
|
||||||
|
return nil, stderr_text ~= "" and stderr_text or ("exit " .. result.status)
|
||||||
|
end
|
||||||
|
|
||||||
|
return result.stdout, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function probe_environment()
|
||||||
|
if state.verified then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok_stdout, err = run_brightnessctl({ "--version" }, "--version")
|
||||||
|
if not ok_stdout then
|
||||||
|
msg.error(("brightnessctl verification failed: %s"):format(err or "unknown"))
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
detect_keyboard_device()
|
||||||
|
|
||||||
|
-- Probe LED availability without caching brightness.
|
||||||
|
local probe, probe_err = run_brightnessctl({ "-m", "get" }, "get")
|
||||||
|
if not probe then
|
||||||
|
msg.error(("Unable to detect keyboard backlight: %s"):format(probe_err or "unknown"))
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
state.verified = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function read_current_brightness()
|
||||||
|
local stdout, err = run_brightnessctl({ "-m", "get" }, "get")
|
||||||
|
if not stdout then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
local cleaned = trim(stdout)
|
||||||
|
if cleaned == "" then
|
||||||
|
msg.error("brightnessctl returned empty output")
|
||||||
|
return nil, "empty output"
|
||||||
|
end
|
||||||
|
|
||||||
|
local direct = tonumber(cleaned)
|
||||||
|
if direct then
|
||||||
|
return direct, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local csv_value = cleaned:match("^[^,]*,[^,]*,[^,]*,(%-?%d+)")
|
||||||
|
if csv_value then
|
||||||
|
return tonumber(csv_value), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local last_numeric
|
||||||
|
for token in cleaned:gmatch("(%-?%d+)") do
|
||||||
|
last_numeric = tonumber(token)
|
||||||
|
end
|
||||||
|
if last_numeric then
|
||||||
|
return last_numeric, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
msg.error(("Failed to parse brightnessctl output: %s"):format(cleaned))
|
||||||
|
return nil, "parse error"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_brightness(target)
|
||||||
|
local stdout, err = run_brightnessctl({ "set", tostring(target) }, ("set %d"):format(target))
|
||||||
|
if not stdout then
|
||||||
|
return false, err
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function emit_intent(intent)
|
||||||
|
local now = mp.get_time()
|
||||||
|
if state.last_intent == intent and (now - state.last_intent_ts) * 1000 < config.debounce_ms then
|
||||||
|
msg.verbose(("Debounced intent '%s'"):format(intent))
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.last_intent = intent
|
||||||
|
state.last_intent_ts = now
|
||||||
|
msg.debug(("Intent: %s"):format(intent))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cancel_timer()
|
||||||
|
if state.timer then
|
||||||
|
state.timer:kill()
|
||||||
|
state.timer = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function arm_disable_callback()
|
||||||
|
state.timer = nil
|
||||||
|
emit_intent("disable_backlight")
|
||||||
|
|
||||||
|
if state.is_dark or state.control_blocked then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local current, err = read_current_brightness()
|
||||||
|
if not current then
|
||||||
|
msg.error(("Unable to cache original brightness: %s"):format(err or "unknown"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.original_brightness = current
|
||||||
|
|
||||||
|
local ok, set_err = set_brightness(config.minimum_brightness)
|
||||||
|
if not ok then
|
||||||
|
msg.error(("Failed to disable keyboard backlight: %s"):format(set_err or "unknown"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
state.is_dark = true
|
||||||
|
msg.info("Keyboard backlight disabled")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_timer()
|
||||||
|
cancel_timer()
|
||||||
|
state.timer = mp.add_timeout(seconds(config.timeout_ms), arm_disable_callback)
|
||||||
|
msg.verbose(("Inactivity timer armed for %.0f ms"):format(config.timeout_ms))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restore_backlight(reason)
|
||||||
|
if not state.is_dark then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
emit_intent("restore_backlight")
|
||||||
|
|
||||||
|
cancel_timer()
|
||||||
|
|
||||||
|
if not state.original_brightness then
|
||||||
|
msg.warn("Original brightness unknown; skipping restore.")
|
||||||
|
state.is_dark = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = set_brightness(state.original_brightness)
|
||||||
|
if not ok then
|
||||||
|
msg.error(
|
||||||
|
("Failed to restore keyboard backlight (%s): %s"):format(
|
||||||
|
reason or "unknown",
|
||||||
|
err or "unknown"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
msg.info(("Keyboard backlight restored (%s)"):format(reason or "event"))
|
||||||
|
state.is_dark = false
|
||||||
|
state.original_brightness = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_play_event(source)
|
||||||
|
if state.control_blocked or not probe_environment() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
emit_intent("disable_backlight_request")
|
||||||
|
|
||||||
|
state.playback_active = true
|
||||||
|
if state.first_frame_seen then
|
||||||
|
schedule_timer()
|
||||||
|
state.pending_timer = false
|
||||||
|
msg.verbose(("Playback active (%s); timer armed."):format(source or "event"))
|
||||||
|
else
|
||||||
|
state.pending_timer = true
|
||||||
|
msg.verbose(
|
||||||
|
("Playback active (%s); waiting for first decoded frame before dimming."):format(
|
||||||
|
source or "event"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_pause_event(source, paused)
|
||||||
|
state.playback_active = not paused
|
||||||
|
if paused then
|
||||||
|
cancel_timer()
|
||||||
|
if config.restore_on_pause then
|
||||||
|
restore_backlight(source or "pause")
|
||||||
|
else
|
||||||
|
msg.verbose("Paused, restore_on_pause disabled; keeping lights off.")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
handle_play_event(source or "pause=no")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_idle_event(tag)
|
||||||
|
state.playback_active = false
|
||||||
|
state.first_frame_seen = false
|
||||||
|
state.pending_timer = false
|
||||||
|
cancel_timer()
|
||||||
|
restore_backlight(tag or "idle")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_pause(_, value)
|
||||||
|
if value == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
handle_pause_event("pause", value)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_playback_time(_, value)
|
||||||
|
if value == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not state.first_frame_seen then
|
||||||
|
state.first_frame_seen = true
|
||||||
|
if state.pending_timer and state.playback_active and not state.timer then
|
||||||
|
schedule_timer()
|
||||||
|
state.pending_timer = false
|
||||||
|
msg.verbose("First frame decoded; inactivity timer armed.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not state.playback_active and not mp.get_property_native("pause") then
|
||||||
|
handle_play_event("playback-time")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_filename(_, value)
|
||||||
|
if not value or value == state.filename then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.filename = value
|
||||||
|
state.first_frame_seen = false
|
||||||
|
state.pending_timer = false
|
||||||
|
msg.verbose(("New media: %s"):format(value))
|
||||||
|
if not mp.get_property_native("pause") then
|
||||||
|
handle_play_event("filename")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mp.observe_property("pause", "bool", on_pause)
|
||||||
|
mp.observe_property("playback-time", "native", on_playback_time)
|
||||||
|
mp.observe_property("filename", "string", on_filename)
|
||||||
|
|
||||||
|
mp.register_event("end-file", function()
|
||||||
|
handle_idle_event("end-file")
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.register_event("shutdown", function()
|
||||||
|
handle_idle_event("shutdown")
|
||||||
|
end)
|
||||||
|
|
||||||
|
msg.info("kb-blackout loaded. Configure permissions with `man brightnessctl` if needed.")
|
||||||
Reference in New Issue
Block a user