--[[ 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 probe_cmd(args) local res = utils.subprocess({ args = args, cancellable = false }) if res and not res.error and res.status == 0 then return res.stdout or "" end return nil end local function pick_volume_backend() if state.volume_backend then return state.volume_backend end local wp = probe_cmd({ config.wpctl_path, "status" }) or probe_cmd({ config.wpctl_path, "--help" }) if wp then state.volume_backend = "wpctl" dbg("using wpctl for volume") return state.volume_backend end local pa = probe_cmd({ config.pactl_path, "info" }) or probe_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)