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