Files
mpv-scripts/scripts/playback-health.lua
Giovanni Harting b890a4efcd feat: add power gauge/delta/minmax to ac-power and extract shared utils
ac-power enhancements:
- Add visual block gauge [████░░░░] colored by power level
- Show delta indicator (+50W) colored by trend (green=dropping, red=rising)
- Track min/max values with persistence to ~/.cache/mpv/
- Multi-process safe file writes (merges values on save)
- New script-message: ac-power-reset-minmax

Shared library (scripts/lib/utils.lua):
- Extract common helpers: trim, clamp, rgb_to_bgr, colorize
- Update all scripts to use shared module via package.path
2025-12-18 17:14:36 +01:00

409 lines
12 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--[[
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")
package.path = mp.command_native({ "expand-path", "~~/scripts/lib/?.lua;" }) .. package.path
local lib = require("utils")
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 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 = lib.colorize("" .. message, col, config.use_colors)
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 = lib.colorize("✓ Recovered", config.color_recovery, config.use_colors)
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 =
lib.colorize("(" .. eta_str .. " to resume)", config.color_info, config.use_colors)
table.insert(parts, pred)
end
end
local text = table.concat(parts, " ")
local styled = lib.colorize("" .. text, config.color_warning, config.use_colors)
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 =
lib.colorize("(stall in " .. eta_str .. ")", config.color_info, config.use_colors)
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)