--[[ touch-gestures.lua - tablet-friendly swipe controls for mpv. Features: * Horizontal swipes anywhere seek forward/backward. * Vertical swipes on the right edge raise/lower system volume; swipes on the left edge adjust screen brightness via brightnessctl. * Gesture axis is locked on first significant movement, preventing accidental cross-axis actions. * 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. Requirements: * mpv 0.39+ recommended for native touch support. * On Wayland, run mpv with `--native-touch=yes` for reliable touch tracking. 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 volume_step_fine = 1, -- step when below fine_threshold brightness_step = 5, -- screen brightness percent per step brightness_step_fine = 1, -- step when below fine_threshold fine_threshold = 10, -- use fine steps below this percent seek_step = 5, -- seconds per seek step max_volume = 130, -- clamp volume to this ceiling min_brightness = 1, -- minimum brightness percent (avoid turning off screen) invert_vertical = false, -- true flips the vertical swipe direction invert_horizontal = false, -- true flips the horizontal 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) top_exclude_ratio = 0, -- ignore top portion (e.g., title bar); 0 disables bottom_exclude_ratio = 0.15, -- ignore bottom portion (e.g., OSC area); 0 disables 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") package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path local lib = require("utils") local state = { active = false, zone = nil, axis = nil, -- "horizontal" or "vertical", locked on first significant movement accum = 0, last_x = 0, last_y = 0, start_x = 0, -- initial touch position for axis detection start_y = 0, button_down = false, -- track if mouse button is held (actual touch vs cursor movement) forwarded_down = false, -- track if we forwarded a click to mpv (need to forward up too) 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 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 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 current_brightness_percent() if state.cached_brightness_pct ~= nil then return state.cached_brightness_pct end local out = run_cmd(brightness_args({ "-m", "info" })) if out then local cur, max = out:match(",(%d+),(%d+),") local c, m = tonumber(cur), tonumber(max) if c and m and m > 0 then state.cached_brightness_pct = lib.clamp((c / m) * 100, 0, 100) return state.cached_brightness_pct end 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 = lib.clamp((c / m) * 100, 0, 100) return state.cached_brightness_pct end local function set_brightness_percent(pct) local target = lib.clamp(pct, config.min_brightness, 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("Volume:%s*([%d%.]+)") or out:match("([%d%.]+)") val = val and tonumber(val) if not val then return nil end state.cached_volume_pct = lib.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 = lib.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 = lib.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, y, width, height) if width <= 0 then return nil end if height and height > 0 then if config.top_exclude_ratio > 0 then local top_cutoff = height * config.top_exclude_ratio if y <= top_cutoff then return nil end end if config.bottom_exclude_ratio > 0 then local bottom_cutoff = height * (1 - config.bottom_exclude_ratio) if y >= bottom_cutoff then return nil end end 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 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 step = current < config.fine_threshold and config.volume_step_fine or config.volume_step local next_value = current + (steps * step) set_volume_percent(next_value) elseif kind == "brightness" then local current = current_brightness_percent() if not current then return end local step = current < config.fine_threshold and config.brightness_step_fine or config.brightness_step local next_value = current + (steps * step) set_brightness_percent(next_value) elseif kind == "seek" then local seconds = steps * config.seek_step mp.commandv("seek", seconds, "relative") show_osd("Seek", string.format("%+ds", seconds)) end end local poll_timer = nil local update_gesture 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.axis = nil state.accum = 0 state.last_x = 0 state.last_y = 0 state.start_x = 0 state.start_y = 0 if poll_timer then poll_timer:kill() poll_timer = nil end end local function poll_position() local pos = mp.get_property_native("mouse-pos") if pos and pos.x and pos.y then update_gesture(nil, pos) end end local function start_poll_timer() if poll_timer then poll_timer:kill() end poll_timer = mp.add_periodic_timer(0.016, poll_position) end local function begin_gesture_tracking(pos) state.active = true state.zone = nil state.axis = nil state.accum = 0 state.last_x = pos.x state.last_y = pos.y state.start_x = pos.x state.start_y = pos.y state.cached_volume_pct = nil state.cached_brightness_pct = nil dbg(string.format("begin tracking at (%.0f,%.0f)", pos.x, pos.y)) start_poll_timer() end local function lock_axis(axis, zone) state.axis = axis state.zone = zone state.accum = 0 dbg(string.format("locked axis=%s zone=%s", axis, tostring(zone))) end update_gesture = function(_, pos) if not pos or not pos.x or not pos.y then return end if not state.active and config.start_on_move and state.button_down then begin_gesture_tracking(pos) end if not state.active then return end local dx = pos.x - state.last_x local dy = pos.y - state.last_y state.last_x = pos.x state.last_y = pos.y if not state.axis then local total_dx = math.abs(pos.x - state.start_x) local total_dy = math.abs(pos.y - state.start_y) if total_dx >= config.deadzone_px or total_dy >= config.deadzone_px then if total_dx > total_dy then lock_axis("horizontal", "seek") else local width, height = surface_size() local zone = pick_zone(state.start_x, state.start_y, width, height) if zone then lock_axis("vertical", zone) else reset_state() return end end else return end end local delta if state.axis == "horizontal" then delta = dx if config.invert_horizontal then delta = -delta end else delta = dy if config.invert_vertical then delta = -delta end end state.accum = state.accum + delta local step_pixels = config.pixels_per_step if config.steps_per_screen and config.steps_per_screen > 0 then local surf_w, surf_h = surface_size() local ref_size = state.axis == "horizontal" and surf_w or surf_h if ref_size and ref_size > 0 then step_pixels = ref_size / 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 axis=%s delta=%.2f accum=%.2f steps=%d", state.zone, state.axis, delta, state.accum, whole_steps ) ) local sign = state.axis == "horizontal" and 1 or -1 apply_steps(state.zone, sign * whole_steps) state.accum = state.accum - (whole_steps * step_pixels) end end local forwarding_click = false local function in_excluded_zone(pos) local _, height = surface_size() if not height or height <= 0 then return false end if config.top_exclude_ratio > 0 then local top_cutoff = height * config.top_exclude_ratio if pos.y <= top_cutoff then return true end end if config.bottom_exclude_ratio > 0 then local bottom_cutoff = height * (1 - config.bottom_exclude_ratio) if pos.y >= bottom_cutoff then return true end end return false end local function handle_touch(event) if forwarding_click then return end if event.event == "down" then state.button_down = true state.forwarded_down = false local pos = mp.get_property_native("mouse-pos") if not pos or not pos.x or not pos.y then return end if in_excluded_zone(pos) then state.forwarded_down = true forwarding_click = true mp.commandv("keydown", "mouse_btn0") forwarding_click = false return end begin_gesture_tracking(pos) elseif event.event == "up" then if state.forwarded_down then forwarding_click = true mp.commandv("keyup", "mouse_btn0") forwarding_click = false elseif state.active and not state.axis then forwarding_click = true mp.commandv("keypress", "mouse_btn0") forwarding_click = false end state.button_down = false state.forwarded_down = false 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) mp.observe_property("touch-pos", "native", function(_, touch) if touch and #touch > 0 then local pos = { x = touch[1].x, y = touch[1].y } if not state.active then if not in_excluded_zone(pos) then begin_gesture_tracking(pos) end else update_gesture(nil, pos) end else if state.active then if not state.axis then forwarding_click = true mp.commandv("keypress", "mouse_btn0") forwarding_click = false end reset_state() end end end) mp.register_event("shutdown", function() reset_state() end) pick_volume_backend()