Files
mpv-scripts/scripts/kb-blackout.lua

441 lines
12 KiB
Lua

--[[
kb-blackout.lua - dims (or disables) the keyboard backlight while mpv is playing.
Features:
* Listens to pause/playback/core-idle so lights only stay off when media is actively playing.
* Captures the session's starting brightness, dims via `brightnessctl`, and restores exactly that value when playback stops.
* Debounces rapid state flips, auto-discovers an LED device (`brightnessctl --list`), and surfaces warnings when Linux, device, or permission prerequisites fail.
Requirements:
* Linux + mpv 0.36+ with the bundled Lua 5.2 runtime.
* `brightnessctl` accessible in PATH or via `brightnessctl_path`, with permission to control the target LED class device.
Configuration:
Edit the `config` table below to tune timeouts, restore behavior, minimum brightness, LED device detection, and the `brightnessctl` binary path.
]]
local config = {
timeout_ms = 3000,
restore_on_pause = true,
minimum_brightness = 0,
led_path = "",
debounce_ms = 250,
brightnessctl_path = "brightnessctl",
}
-- No edits are required below unless you want to change script behavior.
local mp = require("mp")
local msg = require("mp.msg")
local utils = require("mp.utils")
local jit = rawget(_G, "jit")
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.")