--[[ 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)