- 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)
419 lines
12 KiB
Lua
419 lines
12 KiB
Lua
--[[
|
||
playback-health.lua - monitors playback health and warns on problems.
|
||
|
||
Features:
|
||
* Shows OSD warnings only when issues occur (buffering, frame drops, low cache).
|
||
* Silent during normal playback - no clutter when everything is fine.
|
||
* Detects network streams automatically; cache warnings only apply to streams.
|
||
* Frame drop monitoring works for all sources (local and network).
|
||
* Cooldown prevents warning spam; optional recovery message when issues resolve.
|
||
* Color-coded warnings: yellow for warnings, red for critical, green for recovery.
|
||
* Buffer prediction: estimates time until stall or resume based on fill rate.
|
||
* Bandwidth monitoring: warns when download speed can't keep up with playback.
|
||
|
||
Configuration:
|
||
Edit the `config` table below to tune thresholds and display options.
|
||
]]
|
||
|
||
local config = {
|
||
cache_warning_seconds = 5, -- warn when cache falls below this (streams only)
|
||
cache_critical_seconds = 2, -- critical threshold (red warning)
|
||
drop_rate_threshold = 1.0, -- drops per second to trigger warning
|
||
drop_window_seconds = 5, -- time window for measuring drop rate
|
||
|
||
osd_duration = 3, -- how long warnings stay visible
|
||
show_buffering_percent = true, -- show buffer % while buffering
|
||
show_buffer_prediction = true, -- show estimated time to fill/empty
|
||
show_recovery = true, -- show recovery message when issues resolve
|
||
cooldown_seconds = 10, -- minimum time between repeated warnings of same type
|
||
|
||
monitor_cache = true, -- enable low cache warnings (streams)
|
||
monitor_drops = true, -- enable frame drop warnings (all sources)
|
||
monitor_bandwidth = true, -- warn when download speed < playback rate
|
||
bandwidth_ratio_warn = 0.9, -- warn when download/playback ratio below this
|
||
bandwidth_samples = 5, -- samples to average before warning
|
||
bandwidth_buffer_mult = 10, -- skip bandwidth warning if cache above warning × this
|
||
|
||
use_colors = true, -- colorize OSD messages
|
||
color_warning = "FFFF00", -- yellow (RGB hex)
|
||
color_critical = "FF4444", -- red
|
||
color_recovery = "44FF44", -- green
|
||
color_info = "88CCFF", -- light blue for predictions
|
||
|
||
log_debug = false,
|
||
}
|
||
|
||
-- No edits are required below unless you want to change script behavior.
|
||
|
||
local mp = require("mp")
|
||
local msg = require("mp.msg")
|
||
|
||
local state = {
|
||
is_stream = false,
|
||
buffering = false,
|
||
last_drop_count = 0,
|
||
last_drop_check = 0,
|
||
last_warning = {},
|
||
active_warning = nil,
|
||
check_timer = nil,
|
||
cache_history = {},
|
||
last_cache_time = 0,
|
||
cache_rate = nil,
|
||
osd_timer = nil,
|
||
bandwidth_history = {},
|
||
}
|
||
|
||
local function dbg(text)
|
||
if config.log_debug then
|
||
msg.info("playback-health: " .. text)
|
||
end
|
||
end
|
||
|
||
local function rgb_to_bgr(rgb)
|
||
local r = rgb:sub(1, 2)
|
||
local g = rgb:sub(3, 4)
|
||
local b = rgb:sub(5, 6)
|
||
return b .. g .. r
|
||
end
|
||
|
||
local function colorize(text, color_rgb)
|
||
if not config.use_colors then
|
||
return text
|
||
end
|
||
local bgr = rgb_to_bgr(color_rgb)
|
||
return "{\\1c&H" .. bgr .. "&}" .. text .. "{\\1c&HFFFFFF&}"
|
||
end
|
||
|
||
local function clear_osd()
|
||
mp.set_osd_ass(0, 0, "")
|
||
end
|
||
|
||
local function show_osd(text, duration)
|
||
if state.osd_timer then
|
||
state.osd_timer:kill()
|
||
end
|
||
local ass = "{\\an9}" .. text
|
||
mp.set_osd_ass(0, 0, ass)
|
||
state.osd_timer = mp.add_timeout(duration, clear_osd)
|
||
end
|
||
|
||
local function update_cache_rate(duration)
|
||
local now = mp.get_time()
|
||
if state.last_cache_time == 0 then
|
||
state.last_cache_time = now
|
||
state.cache_history = { duration }
|
||
return
|
||
end
|
||
|
||
local elapsed = now - state.last_cache_time
|
||
if elapsed < 0.5 then
|
||
return
|
||
end
|
||
|
||
table.insert(state.cache_history, duration)
|
||
if #state.cache_history > 6 then
|
||
table.remove(state.cache_history, 1)
|
||
end
|
||
|
||
if #state.cache_history >= 2 then
|
||
local oldest = state.cache_history[1]
|
||
local newest = state.cache_history[#state.cache_history]
|
||
local samples = #state.cache_history - 1
|
||
local time_span = elapsed * samples / #state.cache_history
|
||
if time_span > 0 then
|
||
state.cache_rate = (newest - oldest) / time_span
|
||
end
|
||
end
|
||
|
||
state.last_cache_time = now
|
||
end
|
||
|
||
local function predict_buffer_time(current_cache, target_cache)
|
||
if not state.cache_rate or state.cache_rate == 0 then
|
||
return nil
|
||
end
|
||
local diff = target_cache - current_cache
|
||
local time = diff / state.cache_rate
|
||
if time < 0 then
|
||
return nil
|
||
end
|
||
return time
|
||
end
|
||
|
||
local function format_prediction(seconds)
|
||
if not seconds or seconds < 0 then
|
||
return nil
|
||
end
|
||
if seconds < 60 then
|
||
return ("~%.0fs"):format(seconds)
|
||
else
|
||
return ("~%.1fm"):format(seconds / 60)
|
||
end
|
||
end
|
||
|
||
local function format_bitrate(bps)
|
||
if bps >= 1000000 then
|
||
return ("%.1f Mbps"):format(bps / 1000000)
|
||
elseif bps >= 1000 then
|
||
return ("%.0f kbps"):format(bps / 1000)
|
||
else
|
||
return ("%.0f bps"):format(bps)
|
||
end
|
||
end
|
||
|
||
local function is_network_path(path)
|
||
if not path then
|
||
return false
|
||
end
|
||
return path:match("^https?://")
|
||
or path:match("^rtmps?://")
|
||
or path:match("^rtsps?://")
|
||
or path:match("^mms://")
|
||
or path:match("^mmsh://")
|
||
end
|
||
|
||
local function should_warn(warning_type)
|
||
local now = mp.get_time()
|
||
local last = state.last_warning[warning_type]
|
||
if last and (now - last) < config.cooldown_seconds then
|
||
return false
|
||
end
|
||
return true
|
||
end
|
||
|
||
local function show_warning(warning_type, message, color)
|
||
if not should_warn(warning_type) then
|
||
dbg(("suppressed %s warning (cooldown)"):format(warning_type))
|
||
return
|
||
end
|
||
state.last_warning[warning_type] = mp.get_time()
|
||
state.active_warning = warning_type
|
||
local col = color or config.color_warning
|
||
local styled = colorize("⚠ " .. message, col)
|
||
show_osd(styled, config.osd_duration)
|
||
dbg(("warning: %s"):format(message))
|
||
end
|
||
|
||
local function show_recovery()
|
||
if state.active_warning and config.show_recovery then
|
||
local styled = colorize("✓ Recovered", config.color_recovery)
|
||
show_osd(styled, config.osd_duration)
|
||
dbg("recovery shown")
|
||
end
|
||
state.active_warning = nil
|
||
end
|
||
|
||
local function reset_state()
|
||
state.buffering = false
|
||
state.last_drop_count = 0
|
||
state.last_drop_check = 0
|
||
state.last_warning = {}
|
||
state.active_warning = nil
|
||
state.cache_history = {}
|
||
state.last_cache_time = 0
|
||
state.cache_rate = nil
|
||
state.bandwidth_history = {}
|
||
end
|
||
|
||
local function on_path_change(_, path)
|
||
state.is_stream = is_network_path(path)
|
||
reset_state()
|
||
if path then
|
||
dbg(("loaded: %s (stream=%s)"):format(path, tostring(state.is_stream)))
|
||
end
|
||
end
|
||
|
||
local function on_paused_for_cache(_, paused)
|
||
if not state.is_stream then
|
||
return
|
||
end
|
||
if paused and not state.buffering then
|
||
state.buffering = true
|
||
local pct = mp.get_property_number("cache-buffering-state", 0)
|
||
if config.show_buffering_percent then
|
||
show_warning("buffering", ("Buffering... %d%%"):format(pct))
|
||
else
|
||
show_warning("buffering", "Buffering...")
|
||
end
|
||
elseif not paused and state.buffering then
|
||
state.buffering = false
|
||
show_recovery()
|
||
end
|
||
end
|
||
|
||
local function on_cache_buffering_state(_, pct)
|
||
if not state.is_stream or not state.buffering then
|
||
return
|
||
end
|
||
if pct then
|
||
local parts = { "Buffering..." }
|
||
if config.show_buffering_percent then
|
||
table.insert(parts, ("%d%%"):format(pct))
|
||
end
|
||
if config.show_buffer_prediction and state.cache_rate and state.cache_rate > 0 then
|
||
local cache = mp.get_property_number("demuxer-cache-duration", 0)
|
||
local target = config.cache_warning_seconds * 2
|
||
local eta = predict_buffer_time(cache, target)
|
||
local eta_str = format_prediction(eta)
|
||
if eta_str then
|
||
local pred = colorize("(" .. eta_str .. " to resume)", config.color_info)
|
||
table.insert(parts, pred)
|
||
end
|
||
end
|
||
local text = table.concat(parts, " ")
|
||
local styled = colorize("⚠ " .. text, config.color_warning)
|
||
show_osd(styled, config.osd_duration)
|
||
end
|
||
end
|
||
|
||
local function on_cache_duration(_, duration)
|
||
if not state.is_stream or not config.monitor_cache then
|
||
return
|
||
end
|
||
|
||
if duration then
|
||
update_cache_rate(duration)
|
||
end
|
||
|
||
if state.buffering then
|
||
return
|
||
end
|
||
|
||
if duration and duration < config.cache_warning_seconds and duration > 0 then
|
||
local time_remaining = mp.get_property_number("playtime-remaining", 999)
|
||
if time_remaining < config.cache_warning_seconds * 2 then
|
||
return
|
||
end
|
||
|
||
local is_critical = duration < config.cache_critical_seconds
|
||
local color = is_critical and config.color_critical or config.color_warning
|
||
|
||
local parts = { ("Low cache: %.1fs"):format(duration) }
|
||
|
||
if config.show_buffer_prediction and state.cache_rate and state.cache_rate < 0 then
|
||
local eta = predict_buffer_time(duration, 0)
|
||
local eta_str = format_prediction(eta)
|
||
if eta_str then
|
||
local pred = colorize("(stall in " .. eta_str .. ")", config.color_info)
|
||
table.insert(parts, pred)
|
||
end
|
||
end
|
||
|
||
show_warning("cache", table.concat(parts, " "), color)
|
||
end
|
||
end
|
||
|
||
local function check_drop_rate()
|
||
if not config.monitor_drops then
|
||
return
|
||
end
|
||
|
||
local now = mp.get_time()
|
||
local drops = mp.get_property_number("drop-frame-count", 0)
|
||
|
||
if state.last_drop_check == 0 then
|
||
state.last_drop_count = drops
|
||
state.last_drop_check = now
|
||
return
|
||
end
|
||
|
||
local elapsed = now - state.last_drop_check
|
||
if elapsed < config.drop_window_seconds then
|
||
return
|
||
end
|
||
|
||
local new_drops = drops - state.last_drop_count
|
||
local rate = new_drops / elapsed
|
||
|
||
if rate >= config.drop_rate_threshold then
|
||
show_warning("drops", ("Dropping frames: %.1f/s"):format(rate))
|
||
elseif state.active_warning == "drops" and rate < config.drop_rate_threshold / 2 then
|
||
show_recovery()
|
||
end
|
||
|
||
state.last_drop_count = drops
|
||
state.last_drop_check = now
|
||
end
|
||
|
||
local function check_bandwidth()
|
||
if not config.monitor_bandwidth or not state.is_stream or state.buffering then
|
||
return
|
||
end
|
||
|
||
local cache_duration = mp.get_property_number("demuxer-cache-duration", 0)
|
||
if cache_duration >= config.cache_warning_seconds * config.bandwidth_buffer_mult then
|
||
return
|
||
end
|
||
|
||
local cache_state = mp.get_property_native("demuxer-cache-state", {})
|
||
local download_rate = cache_state["raw-input-rate"]
|
||
if not download_rate or download_rate <= 0 then
|
||
return
|
||
end
|
||
|
||
local video_bitrate = mp.get_property_number("video-bitrate", 0)
|
||
local audio_bitrate = mp.get_property_number("audio-bitrate", 0)
|
||
local playback_rate = video_bitrate + audio_bitrate
|
||
|
||
if playback_rate <= 0 then
|
||
return
|
||
end
|
||
|
||
local ratio = (download_rate * 8) / playback_rate
|
||
table.insert(state.bandwidth_history, ratio)
|
||
if #state.bandwidth_history > config.bandwidth_samples then
|
||
table.remove(state.bandwidth_history, 1)
|
||
end
|
||
|
||
if #state.bandwidth_history < config.bandwidth_samples then
|
||
return
|
||
end
|
||
|
||
local avg = 0
|
||
for _, r in ipairs(state.bandwidth_history) do
|
||
avg = avg + r
|
||
end
|
||
avg = avg / #state.bandwidth_history
|
||
|
||
if avg < config.bandwidth_ratio_warn then
|
||
local dl_mbps = format_bitrate(download_rate * 8)
|
||
local need_mbps = format_bitrate(playback_rate)
|
||
local warn_text = ("Slow network: %s < %s needed"):format(dl_mbps, need_mbps)
|
||
show_warning("bandwidth", warn_text, config.color_warning)
|
||
elseif state.active_warning == "bandwidth" and avg > 1.1 then
|
||
show_recovery()
|
||
state.bandwidth_history = {}
|
||
end
|
||
end
|
||
|
||
local function periodic_check()
|
||
check_drop_rate()
|
||
check_bandwidth()
|
||
end
|
||
|
||
local function start_monitoring()
|
||
if state.check_timer then
|
||
state.check_timer:kill()
|
||
end
|
||
state.check_timer = mp.add_periodic_timer(config.drop_window_seconds, periodic_check)
|
||
dbg("monitoring started")
|
||
end
|
||
|
||
local function stop_monitoring()
|
||
if state.check_timer then
|
||
state.check_timer:kill()
|
||
state.check_timer = nil
|
||
end
|
||
reset_state()
|
||
dbg("monitoring stopped")
|
||
end
|
||
|
||
mp.observe_property("path", "string", on_path_change)
|
||
mp.observe_property("paused-for-cache", "bool", on_paused_for_cache)
|
||
mp.observe_property("cache-buffering-state", "number", on_cache_buffering_state)
|
||
mp.observe_property("demuxer-cache-duration", "number", on_cache_duration)
|
||
|
||
mp.register_event("file-loaded", start_monitoring)
|
||
mp.register_event("end-file", stop_monitoring)
|
||
mp.register_event("shutdown", stop_monitoring)
|