Files
mpv-scripts/scripts/touch-gestures.lua

381 lines
10 KiB
Lua

--[[
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)
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")
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, y, width, height)
if width <= 0 then
return nil
end
if height and height > 0 and config.bottom_exclude_ratio > 0 then
local cutoff = height * (1 - config.bottom_exclude_ratio)
if y >= cutoff then
return nil
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
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, height = surface_size()
local zone = pick_zone(pos.x, pos.y, width, height)
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, height = surface_size()
local zone = pick_zone(pos.x, pos.y, width, height)
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)