diff --git a/scripts/playback-health.lua b/scripts/playback-health.lua index a2cad04..5b3d9d0 100644 --- a/scripts/playback-health.lua +++ b/scripts/playback-health.lua @@ -7,6 +7,9 @@ * 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. @@ -14,16 +17,28 @@ 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, } @@ -41,6 +56,11 @@ local state = { 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) @@ -49,6 +69,98 @@ local function dbg(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 @@ -69,20 +181,23 @@ local function should_warn(warning_type) return true end -local function show_warning(warning_type, message) +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 - mp.osd_message("⚠ " .. message, config.osd_duration) + 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 - mp.osd_message("✓ Recovered", config.osd_duration) + local styled = colorize("✓ Recovered", config.color_recovery) + show_osd(styled, config.osd_duration) dbg("recovery shown") end state.active_warning = nil @@ -94,6 +209,10 @@ local function reset_state() 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) @@ -126,8 +245,24 @@ 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) + 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 @@ -135,15 +270,36 @@ 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 - show_warning("cache", ("Low cache: %.1fs remaining"):format(duration)) + + 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 @@ -179,11 +335,67 @@ local function check_drop_rate() 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, check_drop_rate) + state.check_timer = mp.add_periodic_timer(config.drop_window_seconds, periodic_check) dbg("monitoring started") end diff --git a/scripts/touch-gestures.lua b/scripts/touch-gestures.lua index c47b26d..fa1aa08 100644 --- a/scripts/touch-gestures.lua +++ b/scripts/touch-gestures.lua @@ -22,7 +22,10 @@ local config = { 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) @@ -281,14 +284,17 @@ local function apply_steps(kind, steps) if not current then return end - local next_value = current + (steps * config.volume_step) + 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 next_value = current + (steps * config.brightness_step) + 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