--[[ 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 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.")