From 8f33a65c744d2d2a1d375df927fb3d7a1e0351c8 Mon Sep 17 00:00:00 2001 From: Giovanni Harting <539@idlegandalf.com> Date: Fri, 5 Dec 2025 00:57:30 +0100 Subject: [PATCH] feat: add touch swipe system brightness and volume controls --- scripts/touch-gestures.lua | 363 +++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 scripts/touch-gestures.lua diff --git a/scripts/touch-gestures.lua b/scripts/touch-gestures.lua new file mode 100644 index 0000000..54e7576 --- /dev/null +++ b/scripts/touch-gestures.lua @@ -0,0 +1,363 @@ +--[[ + touch-gestures.lua - tablet-friendly swipe controls for mpv. + + Features: + * Vertical swipes on the right edge raise/lower system volume; swipes on the left edge adjust screen brightness via brightnessctl. + * Prefers PipeWire (`wpctl`) for volume, falls back to PulseAudio (`pactl`) automatically. + * Configurable gesture zones, pixels-per-step sensitivity, per-control step sizes, and optional inverted direction. + * Shows OSD feedback after each adjustment so you know the new volume/brightness values. + + Configuration: + Edit the `config` table below to tune zone widths, step sizes, and OSD duration; script-opts files are ignored. +]] + +local config = { + pixels_per_step = 80, -- fallback pixels per step when height unknown + steps_per_screen = 40, -- target steps across full height; 50% height ≈ 100% change with 5% steps + deadzone_px = 4, -- ignore tiny jiggles before the first step (lower = snappier) + volume_step = 5, -- percent per step + brightness_step = 5, -- screen brightness percent per step + max_volume = 130, -- clamp volume to this ceiling + invert_vertical = false, -- true flips the swipe direction + left_zone_ratio = 0.35, -- fraction of width reserved for brightness (left side) + right_zone_ratio = 0.35, -- fraction of width reserved for volume (right side) + osd_duration = 1.2, -- seconds OSD stays visible; set 0 to disable + log_debug = false, -- set true to log gesture details + start_on_move = true, -- begin a gesture on move in edge zones (helps touch devices without BTN events) + brightnessctl_path = "brightnessctl", + brightness_device = "", -- optional; empty lets brightnessctl pick + wpctl_path = "wpctl", + pactl_path = "pactl", +} + +-- 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 show_osd + +local state = { + active = false, + zone = nil, + accum = 0, + last_x = 0, + last_y = 0, + volume_backend = nil, -- "wpctl" or "pactl" + cached_volume_pct = nil, + cached_brightness_pct = nil, +} + +local function dbg(text) + if config.log_debug then + msg.info("touch-gestures: " .. text) + end +end + +local function clamp(value, min_v, max_v) + if value < min_v then + return min_v + end + if value > max_v then + return max_v + end + return value +end + +local function run_cmd(args) + local res = utils.subprocess({ args = args, cancellable = false }) + if not res or res.error or res.status ~= 0 then + local err = res and (res.stderr or res.error) or "unknown" + msg.warn(("touch-gestures cmd failed (%s): %s"):format(table.concat(args, " "), err)) + return nil + end + return res.stdout or "" +end + +local function pick_volume_backend() + if state.volume_backend then + return state.volume_backend + end + local wp = run_cmd({ config.wpctl_path, "--version" }) + if wp then + state.volume_backend = "wpctl" + dbg("using wpctl for volume") + return state.volume_backend + end + local pa = run_cmd({ config.pactl_path, "--version" }) + if pa then + state.volume_backend = "pactl" + dbg("using pactl for volume") + return state.volume_backend + end + msg.error("No volume backend found (wpctl/pactl). Install PipeWire or PulseAudio tools.") + return nil +end + +local function brightness_args(extra) + local args = { config.brightnessctl_path } + if config.brightness_device ~= nil and config.brightness_device ~= "" then + table.insert(args, "-d") + table.insert(args, config.brightness_device) + end + for _, v in ipairs(extra) do + table.insert(args, v) + end + return args +end + +local function current_brightness_percent() + if state.cached_brightness_pct ~= nil then + return state.cached_brightness_pct + end + local cur = run_cmd(brightness_args({ "get" })) + local max = run_cmd(brightness_args({ "max" })) + if not cur or not max then + return nil + end + local c = tonumber(cur) + local m = tonumber(max) + if not c or not m or m <= 0 then + return nil + end + state.cached_brightness_pct = clamp((c / m) * 100, 0, 100) + return state.cached_brightness_pct +end + +local function set_brightness_percent(pct) + local target = clamp(pct, 0, 100) + local ok = run_cmd(brightness_args({ "set", string.format("%.2f%%", target) })) + if ok then + state.cached_brightness_pct = target + show_osd("Brightness", string.format("%d%%", math.floor(target + 0.5))) + end +end + +local function current_volume_percent() + if state.cached_volume_pct ~= nil then + return state.cached_volume_pct + end + local backend = pick_volume_backend() + if backend == "wpctl" then + local out = run_cmd({ config.wpctl_path, "get-volume", "@DEFAULT_AUDIO_SINK@" }) + if not out then + return nil + end + local val = out:match("(%d?%.?%d+)") + val = val and tonumber(val) + if not val then + return nil + end + state.cached_volume_pct = clamp(val * 100, 0, config.max_volume) + return state.cached_volume_pct + elseif backend == "pactl" then + local out = run_cmd({ config.pactl_path, "get-sink-volume", "@DEFAULT_SINK@" }) + if not out then + return nil + end + local pct = out:match("(%d+)%%%s") + pct = pct and tonumber(pct) + if not pct then + return nil + end + state.cached_volume_pct = clamp(pct, 0, config.max_volume) + return state.cached_volume_pct + end + return nil +end + +local function set_volume_percent(pct) + local backend = pick_volume_backend() + if not backend then + return + end + local target = clamp(pct, 0, config.max_volume) + if backend == "wpctl" then + run_cmd({ + config.wpctl_path, + "set-volume", + "@DEFAULT_AUDIO_SINK@", + string.format("%.4f", target / 100), + }) + elseif backend == "pactl" then + run_cmd({ + config.pactl_path, + "set-sink-volume", + "@DEFAULT_SINK@", + string.format("%d%%", math.floor(target + 0.5)), + }) + end + state.cached_volume_pct = target + show_osd("Volume", string.format("%d%%", math.floor(target + 0.5))) +end + +local function surface_size() + local w = mp.get_property_number("osd-width", 0) + local h = mp.get_property_number("osd-height", 0) + if w <= 0 then + w = mp.get_property_number("display-width", 0) + end + if h <= 0 then + h = mp.get_property_number("display-height", 0) + end + return w, h +end + +local function pick_zone(x, width) + if width <= 0 then + return nil + end + local left_limit = width * config.left_zone_ratio + local right_start = width * (1 - config.right_zone_ratio) + if x <= left_limit then + return "brightness" + end + if x >= right_start then + return "volume" + end + return nil +end + +function show_osd(label, value) + if config.osd_duration > 0 then + mp.osd_message(string.format("%s: %s", label, value), config.osd_duration) + end +end + +local function apply_steps(kind, steps) + if steps == 0 then + return + end + if kind == "volume" then + local current = current_volume_percent() + if not current then + return + end + local next_value = current + (steps * config.volume_step) + set_volume_percent(next_value) + elseif kind == "brightness" then + local current = current_brightness_percent() + if not current then + return + end + local next_value = current + (steps * config.brightness_step) + set_brightness_percent(next_value) + end +end + +local function reset_state() + if state.active and config.log_debug then + dbg(string.format("end gesture zone=%s accum=%.2f", tostring(state.zone), state.accum)) + end + state.active = false + state.zone = nil + state.accum = 0 + state.last_x = 0 + state.last_y = 0 +end + +local function start_gesture(pos, zone) + state.active = true + state.zone = zone + state.accum = 0 + state.last_x = pos.x + state.last_y = pos.y + dbg(string.format("start gesture zone=%s at (%.0f,%.0f)", zone, pos.x, pos.y)) +end + +local function update_gesture(_, pos) + if not pos or not pos.x or not pos.y then + return + end + + if not state.active and config.start_on_move then + local width = surface_size() + local zone = pick_zone(pos.x, width) + if zone then + start_gesture(pos, zone) + else + return + end + end + + if not state.active then + return + end + + local dy = pos.y - state.last_y + if config.invert_vertical then + dy = -dy + end + + state.last_x = pos.x + state.last_y = pos.y + + if math.abs(state.accum) < config.deadzone_px then + state.accum = state.accum + dy + if math.abs(state.accum) < config.deadzone_px then + return + end + -- once deadzone crossed, drop the deadzone offset so first step is clean + if state.accum > 0 then + state.accum = state.accum - config.deadzone_px + else + state.accum = state.accum + config.deadzone_px + end + else + state.accum = state.accum + dy + end + + local step_pixels = config.pixels_per_step + if config.steps_per_screen and config.steps_per_screen > 0 then + local _, surf_h = surface_size() + if surf_h and surf_h > 0 then + step_pixels = surf_h / config.steps_per_screen + end + end + if step_pixels <= 0 then + step_pixels = 1 + end + + local steps = state.accum / step_pixels + local whole_steps = steps > 0 and math.floor(steps) or math.ceil(steps) + + if whole_steps ~= 0 then + dbg( + string.format("zone=%s dy=%.2f accum=%.2f steps=%d", state.zone, dy, state.accum, whole_steps) + ) + apply_steps(state.zone, -whole_steps) + state.accum = state.accum - (whole_steps * step_pixels) + end +end + +local forwarding_click = false + +local function handle_touch(event) + if forwarding_click then + return + end + + if event.event == "down" then + local pos = mp.get_property_native("mouse-pos") + if not pos or not pos.x or not pos.y then + return + end + + local width = surface_size() + local zone = pick_zone(pos.x, width) + if not zone then + forwarding_click = true + mp.commandv("keypress", "mouse_btn0") + forwarding_click = false + return + end + + start_gesture(pos, zone) + elseif event.event == "up" then + reset_state() + end +end + +mp.add_forced_key_binding("mouse_btn0", "touch-gestures", handle_touch, { complex = true }) +mp.observe_property("mouse-pos", "native", update_gesture)