--[[ 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. 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) 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_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) 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, } local function dbg(text) if config.log_debug then msg.info("playback-health: " .. text) 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) 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 mp.osd_message("⚠ " .. message, config.osd_duration) dbg(("warning: %s"):format(message)) end local function show_recovery() if state.active_warning and config.show_recovery then mp.osd_message("✓ Recovered", 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 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 and config.show_buffering_percent then mp.osd_message(("⚠ Buffering... %d%%"):format(pct), 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 state.buffering then return end if duration and duration < config.cache_warning_seconds and duration > 0 then show_warning("cache", ("Low cache: %.1fs remaining"):format(duration)) 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 start_monitoring() if state.check_timer then state.check_timer:kill() end state.check_timer = mp.add_periodic_timer(config.drop_window_seconds, check_drop_rate) 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)