- playback-health: skip bandwidth warning when buffer is healthy (configurable via bandwidth_buffer_mult, default 10× warning threshold) - touch-gestures: use finer steps at low volume/brightness (<10%) (configurable via volume_step_fine, brightness_step_fine, fine_threshold)
539 lines
15 KiB
Lua
539 lines
15 KiB
Lua
--[[
|
|
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")
|
|
|
|
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 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 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 = 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 = clamp((c / m) * 100, 0, 100)
|
|
return state.cached_brightness_pct
|
|
end
|
|
|
|
local function set_brightness_percent(pct)
|
|
local target = 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 = 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 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()
|