obs = obslua -- Stream Countdown for OBS -- A "Starting Soon" countdown that switches to your live scene by itself when -- it hits zero. Start it with a simple duration, or target a specific clock -- time (e.g. "20:00 tonight"). Single Lua file, no dependency. -- v1.4.0 local VERSION = "1.4.0" -- ==== Defaults ==== text_source_name = "CountdownText" target_scene_name = "" -- scene to switch to when it hits zero countdown_label = "Starting Soon" display_format = "{label}\n{time}" zero_text = "We're live!" idle_text = "Countdown Ready" duration_minutes = 10 target_hour = 20 target_minute = 0 warn_seconds = 10 -- 0 = warning color off -- Progress bar (rendered on the {bar} token) bar_width = 20 bar_fill = "#" bar_empty = "-" -- Auto-arm: start the countdown by itself when you switch to a chosen scene. arm_on_scene_name = "" -- "" = off arm_mode = "duration" -- "duration" | "target_time" -- Sound (non-blocking via a monitored private media source) beep_last_seconds = 3 -- 0 = off beep_sound_file = "" beep_volume = 80 -- Auto-start (off by default: switching scenes is reversible, starting the -- live/recording is not, so this stays an explicit opt-in). auto_start_stream_at_zero = false auto_start_record_at_zero = false -- Resume a running countdown after a script reload or an OBS restart. persist_state = true -- Waiting room message rotator (optional): rotates follower goals, social -- links, reminders, etc. on a second text source while the countdown runs. waiting_text_source_name = "WaitingMessageText" waiting_rotation_seconds = 8 waiting_messages = {} -- list of strings, empty = feature off -- Colors: 0xAABBGGRR, alpha must be 0xFF or the text is invisible. color_normal = 0xFFFFFFFF -- white color_warning = 0xFF0000FF -- red -- ==== State ==== running = false end_ts = 0 -- absolute os.time when the countdown reaches zero warned_missing_text = false warned_missing_scene = false src_beep = nil waiting_index = 0 waiting_elapsed = 0 total_seconds = 0 -- full length of the current countdown, for the {bar} token self_switching = false -- guard so our own scene switch does not re-trigger auto-arm -- Hotkeys hk_start_duration = obs.OBS_INVALID_HOTKEY_ID hk_start_time = obs.OBS_INVALID_HOTKEY_ID hk_stop = obs.OBS_INVALID_HOTKEY_ID hk_add_min = obs.OBS_INVALID_HOTKEY_ID hk_sub_min = obs.OBS_INVALID_HOTKEY_ID -- ==== Utils ==== local function now() return os.time() end local function fmt_mmss(sec) if sec < 0 then sec = 0 end local h = math.floor(sec / 3600) local m = math.floor((sec % 3600) / 60) local s = sec % 60 if h > 0 then return string.format("%d:%02d:%02d", h, m, s) end return string.format("%02d:%02d", m, s) end local function get_source(name) if not name or name == "" then return nil end return obs.obs_get_source_by_name(name) end local function set_text(name, text) local src = get_source(name) if src then local s = obs.obs_data_create() obs.obs_data_set_string(s, "text", text) obs.obs_source_update(src, s) obs.obs_data_release(s) obs.obs_source_release(src) end end local function set_color(name, color) local src = get_source(name) if src then local s = obs.obs_data_create() obs.obs_data_set_int(s, "color", color) obs.obs_source_update(src, s) obs.obs_data_release(s) obs.obs_source_release(src) end end -- ==== Sound (non-blocking) ==== local function file_exists(path) if not path or path == "" then return false end local f = io.open(path, "rb") if f then f:close(); return true end return false end local function release_sound() if src_beep then obs.obs_source_release(src_beep); src_beep = nil end end local function create_media_source(path) if not file_exists(path) then return nil end local s = obs.obs_data_create() obs.obs_data_set_string(s, "local_file", path) obs.obs_data_set_bool(s, "is_local_file", true) obs.obs_data_set_bool(s, "looping", false) obs.obs_data_set_bool(s, "restart_on_activate", true) local src = obs.obs_source_create_private("ffmpeg_source", "countdown_beep", s) obs.obs_data_release(s) if src then -- Route audio to the mix even though the source is in no scene, so the -- cue is actually audible (same fix as the pomodoro script's sounds). obs.obs_source_set_monitoring_type(src, obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT) obs.obs_source_set_volume(src, math.max(0, math.min(100, beep_volume)) / 100.0) end return src end local function rebuild_sound() release_sound() if beep_last_seconds > 0 and file_exists(beep_sound_file) then src_beep = create_media_source(beep_sound_file) end end local function play_source(src) if not src then return end obs.obs_source_media_restart(src) end -- ==== Display ==== local function show_idle() set_text(text_source_name, idle_text) set_color(text_source_name, color_normal) end -- Waiting room message rotator: independent of the countdown's own text, so -- it can live on a separate source and be styled separately in the scene. local function show_waiting_message(idx) if #waiting_messages == 0 then set_text(waiting_text_source_name, "") return end if idx < 1 or idx > #waiting_messages then idx = 1 end set_text(waiting_text_source_name, waiting_messages[idx]) end local function push_display(time_left, zero_reached) if zero_reached then set_text(text_source_name, zero_text) set_color(text_source_name, color_normal) return end local out = display_format out = out:gsub("{label}", function() return countdown_label end) out = out:gsub("{time}", function() return fmt_mmss(time_left) end) if out:find("{bar}", 1, true) then local total = total_seconds > 0 and total_seconds or 1 local done = total - time_left if done < 0 then done = 0 elseif done > total then done = total end local w = math.max(1, bar_width) local filled = math.floor((done / total) * w + 0.5) local bar = string.rep(bar_fill, filled) .. string.rep(bar_empty, w - filled) out = out:gsub("{bar}", function() return bar end) end set_text(text_source_name, out) if warn_seconds > 0 and time_left <= warn_seconds then set_color(text_source_name, color_warning) else set_color(text_source_name, color_normal) end end local function warn_if_missing_text() if not text_source_name or text_source_name == "" then return end local src = obs.obs_get_source_by_name(text_source_name) if src then obs.obs_source_release(src); warned_missing_text = false elseif not warned_missing_text then obs.script_log(obs.LOG_WARNING, "Stream Countdown: text source '" .. text_source_name .. "' not found.") warned_missing_text = true end end -- ==== Scene switch ==== -- The scene switch is wrapped in pcall: obs_frontend_set_current_scene has -- historically crashed OBS on some versions, and a countdown that can take the -- whole app down at zero is worse than one that just logs and carries on. This -- is the single most important durability guard in the script. local function switch_to_target_scene() if not target_scene_name or target_scene_name == "" then return end local src = obs.obs_get_source_by_name(target_scene_name) if src then self_switching = true local ok = pcall(function() obs.obs_frontend_set_current_scene(src) end) self_switching = false obs.obs_source_release(src) if ok then warned_missing_scene = false else obs.script_log(obs.LOG_WARNING, "Stream Countdown: scene switch to '" .. target_scene_name .. "' failed (OBS API).") end elseif not warned_missing_scene then obs.script_log(obs.LOG_WARNING, "Stream Countdown: scene '" .. target_scene_name .. "' not found, did not switch.") warned_missing_scene = true end end -- Everything that happens once the countdown reaches zero: switch scene, then -- optionally start streaming/recording (both off by default, since those are -- not reversible the way a scene switch is). Shared by the normal tick path -- and by the reload catch-up path (target time passed while OBS was closed). local function reach_zero() running = false switch_to_target_scene() set_text(waiting_text_source_name, "") if auto_start_stream_at_zero and not obs.obs_frontend_streaming_active() then pcall(function() obs.obs_frontend_streaming_start() end) end if auto_start_record_at_zero and not obs.obs_frontend_recording_active() then pcall(function() obs.obs_frontend_recording_start() end) end push_display(0, true) end -- ==== Timer (single source of truth, same guard as the other scripts in this -- repo: OBS repeats a timer callback until removed, so exactly one timer must -- drive the countdown or it would run faster every time Start is pressed). ==== local function tick() if not running then return end local time_left = end_ts - now() if beep_last_seconds > 0 and time_left > 0 and time_left <= beep_last_seconds then play_source(src_beep) end if #waiting_messages > 0 and time_left > 0 then waiting_elapsed = waiting_elapsed + 1 if waiting_elapsed >= math.max(1, waiting_rotation_seconds) then waiting_elapsed = 0 waiting_index = waiting_index + 1 if waiting_index > #waiting_messages then waiting_index = 1 end show_waiting_message(waiting_index) end end if time_left <= 0 then obs.timer_remove(tick) reach_zero() else warn_if_missing_text() push_display(time_left, false) end end local function start_ticking() obs.timer_remove(tick) obs.timer_add(tick, 1000) end -- ==== Controls ==== -- Shared by every way of starting a countdown: arms the timer and resets the -- waiting-room rotation so it always begins on message 1. local function arm_countdown(target_end_ts) obs.timer_remove(tick) running = true end_ts = target_end_ts total_seconds = math.max(1, target_end_ts - now()) waiting_index = 1 waiting_elapsed = 0 show_waiting_message(waiting_index) start_ticking() push_display(end_ts - now(), false) end local function begin_duration_countdown(minutes) arm_countdown(now() + math.max(1, minutes) * 60) end function start_duration_pressed(pressed) if pressed then begin_duration_countdown(duration_minutes) end end function start_preset_5(pressed) if pressed then begin_duration_countdown(5) end end function start_preset_10(pressed) if pressed then begin_duration_countdown(10) end end function start_preset_15(pressed) if pressed then begin_duration_countdown(15) end end function start_preset_30(pressed) if pressed then begin_duration_countdown(30) end end -- Targets the next occurrence of target_hour:target_minute (today if still -- ahead, otherwise tomorrow). Rolling to tomorrow is done by incrementing the -- day field and letting os.time recompute the epoch for that wall-clock time, -- NOT by adding 86400 seconds: across a daylight-saving transition a day is not -- 86400 seconds, so "20:00" stays "20:00" instead of drifting by an hour. function start_to_time_pressed(pressed) if not pressed then return end local t = os.date("*t", now()) t.hour = target_hour; t.min = target_minute; t.sec = 0 local target = os.time(t) if target <= now() then t.day = t.day + 1 -- os.time normalizes the overflow and re-applies DST target = os.time(t) end arm_countdown(target) end -- Auto-arm: called when the user switches to the arm scene. Starts the same way -- the matching button would. local function auto_arm() if arm_mode == "target_time" then start_to_time_pressed(true) else begin_duration_countdown(duration_minutes) end end function stop_pressed(pressed) if not pressed then return end obs.timer_remove(tick) running = false end_ts = 0 set_text(waiting_text_source_name, "") show_idle() end function add_minute_pressed(pressed) if pressed and running then end_ts = end_ts + 60 push_display(end_ts - now(), false) end end function sub_minute_pressed(pressed) if pressed and running then end_ts = end_ts - 60 push_display(math.max(0, end_ts - now()), false) end end -- ==== Language presets ==== -- Fills the on-screen wordings in one click. Does not translate the OBS panel -- itself (that stays in English, standard for OBS scripts). local LANGS = { en = { countdown_label = "Starting Soon", zero_text = "We're live!", idle_text = "Countdown Ready" }, fr = { countdown_label = "On commence bientot", zero_text = "C'est parti !", idle_text = "Pret" }, } function apply_language(s, lang) local t = LANGS[lang] if not t then return false end for k, v in pairs(t) do obs.obs_data_set_string(s, k, v) end return true end local function on_language_changed(props, prop, settings) if apply_language(settings, obs.obs_data_get_string(settings, "language")) then return true end return false end -- ==== Property dropdowns (real scenes / text sources, so no typos) ==== local function populate_scene_list(prop) local scenes = obs.obs_frontend_get_scenes() if scenes then for _, sc in ipairs(scenes) do local nm = obs.obs_source_get_name(sc) if nm and nm ~= "" then obs.obs_property_list_add_string(prop, nm, nm) end end obs.source_list_release(scenes) end end local function populate_text_source_list(prop) local sources = obs.obs_enum_sources() if sources then for _, src in ipairs(sources) do local id = obs.obs_source_get_unversioned_id(src) if id == "text_gdiplus" or id == "text_ft2_source" then local nm = obs.obs_source_get_name(src) if nm and nm ~= "" then obs.obs_property_list_add_string(prop, nm, nm) end end end obs.source_list_release(sources) end end -- Rebuilds the dropdowns from the current scene collection (a scene or source -- added after the panel was opened shows up after pressing this). function btn_refresh_lists(props, p) local function refill(name, populate) local pr = obs.obs_properties_get(props, name) if pr then obs.obs_property_list_clear(pr); populate(pr) end end refill("text_source_name", populate_text_source_list) refill("waiting_text_source_name", populate_text_source_list) refill("target_scene_name", populate_scene_list) refill("arm_on_scene_name", populate_scene_list) return true end -- ==== OBS UI ==== function script_description() return "Stream Countdown v" .. VERSION .. "
A \"Starting Soon\" countdown " .. "that switches to your live scene by itself when it hits zero." .. "

Add a Text source named CountdownText, set the scene to switch to, " .. "then press Start (duration) or Start (to time)." end function script_properties() local p = obs.obs_properties_create() obs.obs_properties_add_text(p, "setup_info", "Add a Text source named CountdownText (or set the name below), set the scene to " .. "switch to when it hits zero, then press Start.", obs.OBS_TEXT_INFO) local tsp = obs.obs_properties_add_list(p, "text_source_name", "Countdown text source", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) populate_text_source_list(tsp) local scp = obs.obs_properties_add_list(p, "target_scene_name", "Switch to this scene at zero", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) populate_scene_list(scp) obs.obs_properties_add_button(p, "btn_refresh", "Refresh scene / source lists", btn_refresh_lists) local lp = obs.obs_properties_add_list(p, "language", "Language preset", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING) obs.obs_property_list_add_string(lp, "Custom", "custom") obs.obs_property_list_add_string(lp, "English", "en") obs.obs_property_list_add_string(lp, "Francais", "fr") obs.obs_property_set_modified_callback(lp, on_language_changed) obs.obs_properties_add_text(p, "countdown_label", "Label", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(p, "zero_text", "Text shown at zero", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(p, "idle_text", "Idle text (before Start)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(p, "format_info", "Display format tokens: {label} {time} {bar}", obs.OBS_TEXT_INFO) obs.obs_properties_add_text(p, "display_format", "Display format", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_int(p, "bar_width", "Progress bar width (for {bar})", 1, 100, 1) obs.obs_properties_add_text(p, "bar_fill", "Progress bar filled char", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(p, "bar_empty", "Progress bar empty char", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_int(p, "duration_minutes", "Duration mode: minutes", 1, 600, 1) obs.obs_properties_add_int(p, "target_hour", "Target time mode: hour (0-23)", 0, 23, 1) obs.obs_properties_add_int(p, "target_minute", "Target time mode: minute (0-59)", 0, 59, 1) obs.obs_properties_add_int(p, "warn_seconds", "Warning color in last N seconds (0 = off)", 0, 3600, 1) obs.obs_properties_add_color(p, "color_normal", "Color: normal") obs.obs_properties_add_color(p, "color_warning", "Color: warning") obs.obs_properties_add_int(p, "beep_last_seconds", "Beep in last N seconds (0 = off)", 0, 60, 1) obs.obs_properties_add_path(p, "beep_sound_file", "Sound: Beep", obs.OBS_PATH_FILE, "*.mp3;*.wav", nil) obs.obs_properties_add_int(p, "beep_volume", "Beep volume (%)", 0, 100, 1) obs.obs_properties_add_bool(p, "auto_start_stream_at_zero", "Start streaming when it hits zero") obs.obs_properties_add_bool(p, "auto_start_record_at_zero", "Start recording when it hits zero") obs.obs_properties_add_bool(p, "persist_state", "Resume the countdown after a reload / OBS restart") obs.obs_properties_add_text(p, "arm_info", "Auto-arm (optional): start the countdown by itself the moment you switch to a chosen ".. "scene (your Starting Soon scene), so you never have to press Start. Leave the scene ".. "name empty to turn it off.", obs.OBS_TEXT_INFO) local asp = obs.obs_properties_add_list(p, "arm_on_scene_name", "Auto-arm when switching to this scene", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) populate_scene_list(asp) local am = obs.obs_properties_add_list(p, "arm_mode", "Auto-arm starts with", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING) obs.obs_property_list_add_string(am, "Duration", "duration") obs.obs_property_list_add_string(am, "Target time", "target_time") obs.obs_properties_add_text(p, "waiting_info", "Waiting room messages (optional): rotate a follower goal, social links, or a Discord ".. "reminder on a SECOND text source while the countdown runs. Create another Text source, ".. "put its name below, and add your messages. Leave the list empty to turn it off.", obs.OBS_TEXT_INFO) local wtp = obs.obs_properties_add_list(p, "waiting_text_source_name", "Waiting room text source (optional)", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING) populate_text_source_list(wtp) obs.obs_properties_add_int(p, "waiting_rotation_seconds", "Seconds per waiting message", 1, 3600, 1) obs.obs_properties_add_editable_list(p, "waiting_messages", "Waiting room messages", obs.OBS_EDITABLE_LIST_TYPE_STRINGS, nil, nil) obs.obs_properties_add_button(p, "btn_start_duration", "Start (duration)", start_duration_pressed) obs.obs_properties_add_button(p, "btn_start_time", "Start (to time)", start_to_time_pressed) obs.obs_properties_add_button(p, "btn_preset_5", "Start 5 min", start_preset_5) obs.obs_properties_add_button(p, "btn_preset_10", "Start 10 min", start_preset_10) obs.obs_properties_add_button(p, "btn_preset_15", "Start 15 min", start_preset_15) obs.obs_properties_add_button(p, "btn_preset_30", "Start 30 min", start_preset_30) obs.obs_properties_add_button(p, "btn_stop", "Stop", stop_pressed) obs.obs_properties_add_button(p, "btn_add_min", "+1 min", add_minute_pressed) obs.obs_properties_add_button(p, "btn_sub_min", "-1 min", sub_minute_pressed) obs.obs_properties_add_text(p, "version_info", "Stream Countdown v" .. VERSION, obs.OBS_TEXT_INFO) return p end function script_defaults(s) obs.obs_data_set_default_string(s, "text_source_name", "CountdownText") obs.obs_data_set_default_string(s, "target_scene_name", "") obs.obs_data_set_default_string(s, "countdown_label", "Starting Soon") obs.obs_data_set_default_string(s, "zero_text", "We're live!") obs.obs_data_set_default_string(s, "idle_text", "Countdown Ready") obs.obs_data_set_default_string(s, "display_format", "{label}\n{time}") obs.obs_data_set_default_int(s, "duration_minutes", 10) obs.obs_data_set_default_int(s, "target_hour", 20) obs.obs_data_set_default_int(s, "target_minute", 0) obs.obs_data_set_default_int(s, "warn_seconds", 10) obs.obs_data_set_default_int(s, "color_normal", color_normal) obs.obs_data_set_default_int(s, "color_warning", color_warning) obs.obs_data_set_default_int(s, "beep_last_seconds", 3) obs.obs_data_set_default_int(s, "beep_volume", 80) obs.obs_data_set_default_bool(s, "auto_start_stream_at_zero", false) obs.obs_data_set_default_bool(s, "auto_start_record_at_zero", false) obs.obs_data_set_default_bool(s, "persist_state", true) obs.obs_data_set_default_string(s, "waiting_text_source_name", "WaitingMessageText") obs.obs_data_set_default_int(s, "waiting_rotation_seconds", 8) obs.obs_data_set_default_int(s, "bar_width", 20) obs.obs_data_set_default_string(s, "bar_fill", "#") obs.obs_data_set_default_string(s, "bar_empty", "-") obs.obs_data_set_default_string(s, "language", "custom") obs.obs_data_set_default_string(s, "arm_on_scene_name", "") obs.obs_data_set_default_string(s, "arm_mode", "duration") end -- Reads an editable-list-of-strings setting into a plain Lua array. local function read_editable_list(settings, name) local out = {} local arr = obs.obs_data_get_array(settings, name) local count = obs.obs_data_array_count(arr) for i = 0, count - 1 do local item = obs.obs_data_array_item(arr, i) local v = obs.obs_data_get_string(item, "value") if v and v ~= "" then out[#out + 1] = v end obs.obs_data_release(item) end obs.obs_data_array_release(arr) return out end function script_update(s) text_source_name = obs.obs_data_get_string(s, "text_source_name") target_scene_name = obs.obs_data_get_string(s, "target_scene_name") countdown_label = obs.obs_data_get_string(s, "countdown_label") zero_text = obs.obs_data_get_string(s, "zero_text") idle_text = obs.obs_data_get_string(s, "idle_text") display_format = obs.obs_data_get_string(s, "display_format") if display_format == "" then display_format = "{label}\n{time}" end duration_minutes = math.max(1, obs.obs_data_get_int(s, "duration_minutes")) target_hour = math.max(0, math.min(23, obs.obs_data_get_int(s, "target_hour"))) target_minute = math.max(0, math.min(59, obs.obs_data_get_int(s, "target_minute"))) warn_seconds = math.max(0, obs.obs_data_get_int(s, "warn_seconds")) color_normal = obs.obs_data_get_int(s, "color_normal") color_warning = obs.obs_data_get_int(s, "color_warning") beep_last_seconds = math.max(0, obs.obs_data_get_int(s, "beep_last_seconds")) beep_sound_file = obs.obs_data_get_string(s, "beep_sound_file") beep_volume = obs.obs_data_get_int(s, "beep_volume") auto_start_stream_at_zero = obs.obs_data_get_bool(s, "auto_start_stream_at_zero") auto_start_record_at_zero = obs.obs_data_get_bool(s, "auto_start_record_at_zero") persist_state = obs.obs_data_get_bool(s, "persist_state") waiting_text_source_name = obs.obs_data_get_string(s, "waiting_text_source_name") waiting_rotation_seconds = math.max(1, obs.obs_data_get_int(s, "waiting_rotation_seconds")) waiting_messages = read_editable_list(s, "waiting_messages") bar_width = math.max(1, obs.obs_data_get_int(s, "bar_width")) bar_fill = obs.obs_data_get_string(s, "bar_fill"); if bar_fill == "" then bar_fill = "#" end bar_empty = obs.obs_data_get_string(s, "bar_empty"); if bar_empty == "" then bar_empty = "-" end arm_on_scene_name = obs.obs_data_get_string(s, "arm_on_scene_name") arm_mode = obs.obs_data_get_string(s, "arm_mode"); if arm_mode == "" then arm_mode = "duration" end if waiting_index > #waiting_messages then waiting_index = 0 end warned_missing_text = false warned_missing_scene = false rebuild_sound() if not running then show_idle() set_text(waiting_text_source_name, "") end end function script_load(settings) hk_start_duration = obs.obs_hotkey_register_frontend("countdown_start_duration", "Stream Countdown: Start (duration)", function(pressed) if pressed then start_duration_pressed(true) end end) hk_start_time = obs.obs_hotkey_register_frontend("countdown_start_time", "Stream Countdown: Start (to time)", function(pressed) if pressed then start_to_time_pressed(true) end end) hk_stop = obs.obs_hotkey_register_frontend("countdown_stop", "Stream Countdown: Stop", function(pressed) if pressed then stop_pressed(true) end end) hk_add_min = obs.obs_hotkey_register_frontend("countdown_add_min", "Stream Countdown: +1 min", function(pressed) if pressed then add_minute_pressed(true) end end) hk_sub_min = obs.obs_hotkey_register_frontend("countdown_sub_min", "Stream Countdown: -1 min", function(pressed) if pressed then sub_minute_pressed(true) end end) local function hk_load(id, name) local arr = obs.obs_data_get_array(settings, name) obs.obs_hotkey_load(id, arr) obs.obs_data_array_release(arr) end hk_load(hk_start_duration, "countdown_start_duration") hk_load(hk_start_time, "countdown_start_time") hk_load(hk_stop, "countdown_stop") hk_load(hk_add_min, "countdown_add_min") hk_load(hk_sub_min, "countdown_sub_min") -- Auto-arm: when the user switches to the arm scene, start the countdown by -- itself. self_switching guards against our own end-of-countdown switch, and -- we ignore the case where the arm scene equals the target scene (a config -- loop). Only arms when nothing is already running. obs.obs_frontend_add_event_callback(function(ev) if ev == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then if self_switching or running or arm_on_scene_name == "" or arm_on_scene_name == target_scene_name then return end local cur = obs.obs_frontend_get_current_scene() if cur then local nm = obs.obs_source_get_name(cur) obs.obs_source_release(cur) if nm == arm_on_scene_name then auto_arm() end end end end) -- Resume a countdown that was running before a script reload or an OBS -- restart. Deliberately do not act synchronously here: script_update runs -- right after script_load and only then are target_scene_name and the -- auto-start options current. Just re-arm the timer; the first real tick -- (fired after script_update) will correctly resume the countdown, or if -- the target time already passed while OBS was closed, immediately reach -- zero and switch the scene rather than silently dropping it. if obs.obs_data_get_bool(settings, "persist_state") and obs.obs_data_get_bool(settings, "st_running") then end_ts = obs.obs_data_get_int(settings, "st_end_ts") waiting_index = obs.obs_data_get_int(settings, "st_waiting_index") total_seconds = obs.obs_data_get_int(settings, "st_total_seconds") running = true start_ticking() end end function script_save(settings) local function hk_save(id, name) local arr = obs.obs_hotkey_save(id) obs.obs_data_set_array(settings, name, arr) obs.obs_data_array_release(arr) end hk_save(hk_start_duration, "countdown_start_duration") hk_save(hk_start_time, "countdown_start_time") hk_save(hk_stop, "countdown_stop") hk_save(hk_add_min, "countdown_add_min") hk_save(hk_sub_min, "countdown_sub_min") if persist_state then obs.obs_data_set_bool(settings, "st_running", running) obs.obs_data_set_int(settings, "st_end_ts", end_ts) obs.obs_data_set_int(settings, "st_waiting_index", waiting_index) obs.obs_data_set_int(settings, "st_total_seconds", total_seconds) end end function script_unload() obs.timer_remove(tick) release_sound() end