obs = obslua
-- Pomodoro Pro Timer for OBS
-- v3.3.0
local VERSION = "3.3.0"
-- ==== Defaults ====
timer_source_name = "PomodoroTimer"
status_source_name = ""
focus_minutes = 25
short_break_minutes = 5
long_break_minutes = 15
sessions_before_long = 4
cycles_before_stop = 0 -- 0 = loop forever
show_mode_label = true
show_session_counter = true
session_label = "Session"
focus_message = "Focus Time!"
short_break_message = "Short Break!"
long_break_message = "Long Break!"
stopped_message = "Timer Stopped"
paused_message = "Paused"
label_focus = "FOCUS"
label_short_break = "BREAK"
label_long_break = "LONG BREAK"
-- Display
display_format = "{label}{time}{session}{end}{total}"
show_end_time = true
end_time_label = "Ends at"
end_time_inline = true
sep_char = "·"
use_24h = true
-- Progress bar (rendered on the {bar} token)
bar_width = 10
bar_fill = "#"
bar_empty = "-"
-- Total focus done (session stat)
show_total_focus = false
total_focus_label = "Done"
-- Idle / behavior
idle_show_focus_duration = true -- when stopped, show the focus duration instead of 00:00
hide_when_stopped = false -- blank the timer text when stopped
idle_text = "" -- custom text shown when stopped (overrides the duration)
-- Sounds (non-blocking via monitored private media sources)
enable_sounds = true
sound_volume = 80 -- percent
sound_focus_file = ""
sound_short_file = ""
sound_long_file = ""
beep_last_seconds = 0 -- play a beep during the last N seconds (0 = off)
beep_sound_file = ""
-- Colors for the timer text source.
-- OBS stores color as 0xAABBGGRR (alpha in the high byte). Alpha must be 0xFF
-- or the text is invisible, and the low byte is Red (not Blue).
color_focus = 0xFF00FF00 -- green
color_short_break = 0xFF00FFFF -- yellow
color_long_break = 0xFF0000FF -- red
color_paused = 0xFFFFFFFF -- white
color_stopped = 0xFFAAAAAA -- grey
color_warning = 0xFF0000FF -- red, used in the last N seconds
warn_seconds = 0 -- 0 = warning color off
-- Auto-start
auto_scene_name = ""
auto_start_on_stream = false
auto_start_on_record = false
auto_stop_on_leave_scene = false
stream_stop_action = "none" -- "none" | "pause" | "reset" on streaming stopped
record_stop_action = "none" -- "none" | "pause" | "reset" on recording stopped
-- Scene and mic automation
break_scene_name = "" -- switch to this OBS scene during breaks
focus_scene_name = "" -- switch to this OBS scene during focus
mute_mic_on_break = false
mic_source_name = "" -- audio source muted during breaks
-- Focus behavior
manual_advance = false -- wait for Skip before moving to the next segment
-- Persistence
persist_state = true -- resume the running timer after a script reload / OBS restart
-- State
mode = "stopped" -- "focus" | "short_break" | "long_break" | "paused" | "stopped"
prev_mode = "focus"
time_left = 0
segment_end_ts = 0 -- absolute os.time when the running segment ends (wall-clock anchor)
segment_total = 0 -- full length of the current segment (for the progress bar)
session_count = 0
cycles_completed = 0
total_focus_completed = 0
timer_running = false
awaiting_advance = false -- holding at 00:00 in manual-advance mode
warned_missing = false
-- Sound source handles
src_focus = nil
src_short = nil
src_long = nil
src_beep = nil
-- Hotkeys
hk_start = obs.OBS_INVALID_HOTKEY_ID
hk_pause = obs.OBS_INVALID_HOTKEY_ID
hk_resume = obs.OBS_INVALID_HOTKEY_ID
hk_toggle = obs.OBS_INVALID_HOTKEY_ID
hk_stop = obs.OBS_INVALID_HOTKEY_ID
hk_reset = obs.OBS_INVALID_HOTKEY_ID
hk_skip = obs.OBS_INVALID_HOTKEY_ID
hk_addmin = obs.OBS_INVALID_HOTKEY_ID
hk_submin = obs.OBS_INVALID_HOTKEY_ID
-- ==== Utils ====
local function now() return os.time() end
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 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
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) else return string.format("%02d:%02d", m, s) end
end
local function fmt_clock(ts)
return os.date(use_24h and "%H:%M" or "%I:%M %p", ts)
end
local function current_label()
if mode == "focus" then return label_focus
elseif mode == "short_break" then return label_short_break
elseif mode == "long_break" then return label_long_break
elseif mode == "paused" then return paused_message
else return stopped_message end
end
local function session_suffix()
if not show_session_counter then return "" end
local idx = session_count
if mode == "focus" then idx = session_count + 1 end
if sessions_before_long > 0 then
return string.format(" (%s %d of %d)", session_label, idx, sessions_before_long)
else
return string.format(" (%s %d)", session_label, idx)
end
end
-- ==== Sounds (non-blocking) ====
local function release_sound_sources()
if src_focus then obs.obs_source_release(src_focus); src_focus = nil end
if src_short then obs.obs_source_release(src_short); src_short = nil end
if src_long then obs.obs_source_release(src_long); src_long = nil end
if src_beep then obs.obs_source_release(src_beep); src_beep = nil end
end
local function create_media_source(path, tag)
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", "pomo_snd_"..tag, 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. Without this a private media source stays silent.
obs.obs_source_set_monitoring_type(src, obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT)
local v = math.max(0, math.min(100, sound_volume)) / 100.0
obs.obs_source_set_volume(src, v)
end
return src
end
local function rebuild_sound_sources()
release_sound_sources()
if enable_sounds and file_exists(sound_focus_file) then src_focus = create_media_source(sound_focus_file, "focus") end
if enable_sounds and file_exists(sound_short_file) then src_short = create_media_source(sound_short_file, "short") end
if enable_sounds and file_exists(sound_long_file) then src_long = create_media_source(sound_long_file, "long") end
if enable_sounds and file_exists(beep_sound_file) then src_beep = create_media_source(beep_sound_file, "beep") end
end
local function play_source(src)
if not src or not enable_sounds then return end
obs.obs_source_media_restart(src)
end
local function play_cue(m)
if m == "focus" then play_source(src_focus)
elseif m == "short_break" then play_source(src_short)
elseif m == "long_break" then play_source(src_long)
end
end
-- ==== Timer management (forward declared so end_of_segment can stop the clock) ====
-- OBS repeats a timer callback until removed. The countdown must be driven by
-- exactly one tick timer, otherwise it runs N times too fast (stacked timers).
local stop_ticking, start_ticking
-- ==== Core ====
local function warn_if_missing()
if not timer_source_name or timer_source_name == "" then return end
local src = obs.obs_get_source_by_name(timer_source_name)
if src then
obs.obs_source_release(src)
warned_missing = false
elseif not warned_missing then
obs.script_log(obs.LOG_WARNING,
"Pomodoro: text source '" .. timer_source_name ..
"' not found. Create a Text source with that exact name.")
warned_missing = true
end
end
local function assemble()
local label = ""
if show_mode_label and (mode == "focus" or mode == "short_break" or mode == "long_break") then
label = current_label() .. " " .. sep_char .. " "
end
local time = fmt_mmss(time_left)
local session = (mode ~= "stopped" and mode ~= "paused") and session_suffix() or ""
local endf = ""
if show_end_time and mode ~= "stopped" and mode ~= "paused" then
local ec = fmt_clock(now() + time_left)
if end_time_inline then
endf = " " .. sep_char .. " " .. end_time_label .. " " .. ec
else
endf = "\n" .. end_time_label .. " " .. ec
end
end
local total = ""
if show_total_focus and total_focus_completed > 0 and mode ~= "stopped" and mode ~= "paused" then
total = " " .. sep_char .. " " .. total_focus_label .. " " .. tostring(total_focus_completed)
end
-- Progress fraction of the current segment, shared by {bar} {percent} {elapsed}.
local total_len = segment_total > 0 and segment_total or 1
local done = total_len - time_left
if done < 0 then done = 0 elseif done > total_len then done = total_len end
local frac = done / total_len
local bar = ""
if display_format:find("{bar}", 1, true) then
local w = math.max(1, bar_width)
local filled = math.floor(frac * w + 0.5)
bar = string.rep(bar_fill, filled) .. string.rep(bar_empty, w - filled)
end
local percent = string.format("%d%%", math.floor(frac * 100 + 0.5))
local elapsed = fmt_mmss(done)
-- Function replacements avoid magic-character issues in the substituted text.
local out = display_format
out = out:gsub("{label}", function() return label end)
out = out:gsub("{time}", function() return time end)
out = out:gsub("{session}", function() return session end)
out = out:gsub("{end}", function() return endf end)
out = out:gsub("{total}", function() return total end)
out = out:gsub("{bar}", function() return bar end)
out = out:gsub("{percent}", function() return percent end)
out = out:gsub("{elapsed}", function() return elapsed end)
return out
end
local function push_display()
warn_if_missing()
local display
if mode == "stopped" then
if hide_when_stopped then
display = ""
elseif idle_text ~= "" then
display = idle_text
elseif idle_show_focus_duration then
display = fmt_mmss(math.max(1, focus_minutes) * 60)
else
display = fmt_mmss(time_left)
end
else
display = assemble()
end
set_text(timer_source_name, display)
local col
if mode == "stopped" then
col = color_stopped
elseif mode == "paused" then
col = color_paused
elseif warn_seconds > 0 and time_left <= warn_seconds then
col = color_warning
else
local cmap = { focus = color_focus, short_break = color_short_break, long_break = color_long_break }
col = cmap[mode] or 0xFFFFFFFF
end
set_color(timer_source_name, col)
if status_source_name ~= "" then
local m = { focus = focus_message, short_break = short_break_message,
long_break = long_break_message, paused = paused_message, stopped = stopped_message }
set_text(status_source_name, m[mode])
end
end
local function switch_scene(name)
if not name or name == "" then return end
local src = obs.obs_get_source_by_name(name)
if src then
obs.obs_frontend_set_current_scene(src)
obs.obs_source_release(src)
end
end
local function apply_scene_and_mic()
if mode == "focus" then switch_scene(focus_scene_name)
elseif mode == "short_break" or mode == "long_break" then switch_scene(break_scene_name) end
if mute_mic_on_break and mic_source_name ~= "" then
local src = obs.obs_get_source_by_name(mic_source_name)
if src then
obs.obs_source_set_muted(src, mode == "short_break" or mode == "long_break")
obs.obs_source_release(src)
end
end
end
local function set_mode(new_mode)
mode = new_mode
if mode == "focus" then time_left = math.max(1, focus_minutes) * 60
elseif mode == "short_break" then time_left = math.max(1, short_break_minutes) * 60
elseif mode == "long_break" then time_left = math.max(1, long_break_minutes) * 60
end
if mode == "focus" or mode == "short_break" or mode == "long_break" then
segment_end_ts = now() + time_left
segment_total = time_left
end
apply_scene_and_mic()
push_display()
end
local function end_of_segment()
if mode == "focus" then
session_count = session_count + 1
total_focus_completed = total_focus_completed + 1
if sessions_before_long > 0 and (session_count % sessions_before_long == 0) then
set_mode("long_break"); play_cue("long_break")
else
set_mode("short_break"); play_cue("short_break")
end
elseif mode == "long_break" then
-- A long break closes the cycle: reset the session count so the display
-- reads "Session 1 of 4" again instead of "Session 5 of 4".
cycles_completed = cycles_completed + 1
session_count = 0
if cycles_before_stop > 0 and cycles_completed >= cycles_before_stop then
stop_ticking(); timer_running = false; mode = "stopped"; push_display()
else
set_mode("focus"); play_cue("focus")
end
else
set_mode("focus"); play_cue("focus")
end
end
-- Wall-clock accurate: derive the remaining time from an absolute end timestamp,
-- so the countdown stays correct even if OBS delays or drops a timer tick.
local function tick()
if not timer_running or mode == "paused" or mode == "stopped" or awaiting_advance then return end
time_left = segment_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 time_left <= 0 then
time_left = 0
if manual_advance then
awaiting_advance = true -- hold at 00:00 until the user presses Skip
push_display()
else
end_of_segment()
end
else
push_display()
end
end
function stop_ticking()
obs.timer_remove(tick)
end
function start_ticking()
stop_ticking()
obs.timer_add(tick, 1000)
end
local function advance_segment()
awaiting_advance = false
end_of_segment()
end
local function unmute_mic()
if mute_mic_on_break and mic_source_name ~= "" then
local src = obs.obs_get_source_by_name(mic_source_name)
if src then obs.obs_source_set_muted(src, false); obs.obs_source_release(src) end
end
end
-- ==== Controls ====
function start_pressed(pressed)
if not pressed then return end
timer_running = true
awaiting_advance = false
session_count = 0
cycles_completed = 0
total_focus_completed = 0
set_mode("focus")
start_ticking()
end
function add_minute_pressed(pressed)
if pressed and timer_running and (mode == "focus" or mode == "short_break" or mode == "long_break") then
segment_end_ts = segment_end_ts + 60
segment_total = segment_total + 60
time_left = segment_end_ts - now()
push_display()
end
end
function sub_minute_pressed(pressed)
if pressed and timer_running and (mode == "focus" or mode == "short_break" or mode == "long_break") then
segment_end_ts = segment_end_ts - 60
segment_total = math.max(1, segment_total - 60)
time_left = math.max(0, segment_end_ts - now())
push_display()
end
end
function pause_pressed(pressed)
if pressed and timer_running and (mode == "focus" or mode == "short_break" or mode == "long_break") then
time_left = math.max(0, segment_end_ts - now()) -- freeze accurate remaining
prev_mode = mode
mode = "paused"
push_display()
end
end
function resume_pressed(pressed)
if pressed and mode == "paused" then
mode = prev_mode or "focus"
segment_end_ts = now() + time_left -- re-anchor to wall clock
push_display()
end
end
function toggle_pause_pressed(pressed)
if not pressed then return end
if mode == "paused" then
resume_pressed(true)
elseif timer_running and (mode == "focus" or mode == "short_break" or mode == "long_break") then
pause_pressed(true)
end
end
function stop_pressed(pressed)
-- Halt the timer but keep the session and total counters, unlike Reset.
if pressed then
stop_ticking(); timer_running = false; awaiting_advance = false; mode = "stopped"
unmute_mic(); push_display()
end
end
function reset_pressed(pressed)
if pressed then
stop_ticking(); timer_running = false; awaiting_advance = false; mode = "stopped"
session_count = 0; cycles_completed = 0; total_focus_completed = 0; time_left = 0
unmute_mic(); push_display()
end
end
function skip_pressed(pressed)
if not pressed then return end
if awaiting_advance then advance_segment()
elseif timer_running and mode ~= "stopped" then advance_segment() end
end
-- ==== Presets and actions ====
-- Language presets: fill every wording at once. Exposed as globals for testing.
local LANGS = {
en = { label_focus = "FOCUS", label_short_break = "BREAK", label_long_break = "LONG BREAK",
focus_message = "Focus Time!", short_break_message = "Short Break!", long_break_message = "Long Break!",
paused_message = "Paused", stopped_message = "Timer Stopped", session_label = "Session",
end_time_label = "Ends at", total_focus_label = "Done" },
fr = { label_focus = "FOCUS", label_short_break = "PAUSE", label_long_break = "PAUSE LONGUE",
focus_message = "Concentration", short_break_message = "Petite pause", long_break_message = "Grande pause",
paused_message = "En pause", stopped_message = "Arrete", session_label = "Session",
end_time_label = "Fin a", total_focus_label = "Faits" },
}
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 DURATION_PRESETS = {
classic = { focus_minutes = 25, short_break_minutes = 5, long_break_minutes = 15, sessions_before_long = 4 },
deepwork = { focus_minutes = 50, short_break_minutes = 10, long_break_minutes = 30, sessions_before_long = 3 },
short = { focus_minutes = 15, short_break_minutes = 3, long_break_minutes = 10, sessions_before_long = 4 },
}
function apply_duration_preset(s, name)
local t = DURATION_PRESETS[name]
if not t then return false end
for k, v in pairs(t) do obs.obs_data_set_int(s, k, v) end
return true
end
local function on_language_changed(props, prop, settings)
local lang = obs.obs_data_get_string(settings, "language")
if apply_language(settings, lang) then return true end
return false
end
local function on_duration_preset_changed(props, prop, settings)
local name = obs.obs_data_get_string(settings, "duration_preset")
if apply_duration_preset(settings, name) then return true end
return false
end
local function apply_stop_action(action)
if action == "pause" then pause_pressed(true)
elseif action == "reset" then reset_pressed(true)
end
end
-- ==== OBS UI ====
function script_properties()
local p = obs.obs_properties_create()
obs.obs_properties_add_text(p, "setup_info",
"Setup: add a Text source to your scene, name it exactly like the Timer "..
"text source field below (default PomodoroTimer), then press Start.", obs.OBS_TEXT_INFO)
obs.obs_properties_add_text(p, "timer_source_name", "Timer text source", obs.OBS_TEXT_DEFAULT)
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)
local dp = obs.obs_properties_add_list(p, "duration_preset", "Duration preset",
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
obs.obs_property_list_add_string(dp, "Custom", "custom")
obs.obs_property_list_add_string(dp, "Classic 25 / 5 / 15", "classic")
obs.obs_property_list_add_string(dp, "Deep work 50 / 10 / 30", "deepwork")
obs.obs_property_list_add_string(dp, "Short 15 / 3 / 10", "short")
obs.obs_property_set_modified_callback(dp, on_duration_preset_changed)
obs.obs_properties_add_text(p, "status_info",
"Status text (optional): a SECOND text source that shows a written status "..
"next to the countdown, for example 'Focus Time!' during focus and "..
"'Short Break!' during a break. Create another Text source, put its name "..
"below, and edit the wordings under it. Leave the name empty to turn it off.",
obs.OBS_TEXT_INFO)
obs.obs_properties_add_text(p, "status_source_name", "Status text source (optional)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "focus_message", "Status wording: Focus", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "short_break_message", "Status wording: Short Break", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "long_break_message", "Status wording: Long Break", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "paused_message", "Status wording: Paused", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "stopped_message", "Status wording: Stopped", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_int(p, "focus_minutes", "Focus (min)", 1, 240, 1)
obs.obs_properties_add_int(p, "short_break_minutes", "Short Break (min)", 1, 120, 1)
obs.obs_properties_add_int(p, "long_break_minutes", "Long Break (min)", 1, 240, 1)
obs.obs_properties_add_int(p, "sessions_before_long", "Sessions before Long Break", 1, 24, 1)
obs.obs_properties_add_int(p, "cycles_before_stop", "Stop after N full cycles (0 = loop forever)", 0, 99, 1)
obs.obs_properties_add_bool(p, "manual_advance", "Manual advance (wait for Skip between segments)")
obs.obs_properties_add_bool(p, "show_mode_label", "Show mode label")
obs.obs_properties_add_text(p, "label_focus", "Timer label: Focus", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "label_short_break", "Timer label: Break", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "label_long_break", "Timer label: Long Break", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "show_session_counter", "Show session counter")
obs.obs_properties_add_text(p, "session_label", "Session Label", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "show_end_time", "Show end time")
obs.obs_properties_add_bool(p, "end_time_inline", "End time inline")
obs.obs_properties_add_text(p, "end_time_label", "End time label", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "sep_char", "Separator", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "use_24h", "Use 24h clock")
obs.obs_properties_add_text(p, "format_info",
"Display format tokens: {label} {time} {session} {end} {total} {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_bool(p, "show_total_focus", "Show total focus done")
obs.obs_properties_add_text(p, "total_focus_label", "Total focus label", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "idle_show_focus_duration", "When stopped, show the focus duration")
obs.obs_properties_add_bool(p, "hide_when_stopped", "Hide the timer text when stopped")
obs.obs_properties_add_text(p, "idle_text", "Idle text when stopped (optional)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "enable_sounds", "Enable Sounds")
obs.obs_properties_add_int(p, "sound_volume", "Sound volume (%)", 0, 100, 1)
obs.obs_properties_add_path(p, "sound_focus_file", "Sound: Focus", obs.OBS_PATH_FILE, "*.mp3;*.wav", nil)
obs.obs_properties_add_path(p, "sound_short_file", "Sound: Short Break", obs.OBS_PATH_FILE, "*.mp3;*.wav", nil)
obs.obs_properties_add_path(p, "sound_long_file", "Sound: Long Break", obs.OBS_PATH_FILE, "*.mp3;*.wav", nil)
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_color(p, "color_focus", "Color: Focus")
obs.obs_properties_add_color(p, "color_short_break", "Color: Short Break")
obs.obs_properties_add_color(p, "color_long_break", "Color: Long Break")
obs.obs_properties_add_color(p, "color_paused", "Color: Paused")
obs.obs_properties_add_color(p, "color_stopped", "Color: Stopped")
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_warning", "Color: Warning")
obs.obs_properties_add_text(p, "auto_scene_name", "Auto-start on Scene", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "auto_start_on_stream", "Auto-start when streaming starts")
obs.obs_properties_add_bool(p, "auto_start_on_record", "Auto-start when recording starts")
obs.obs_properties_add_bool(p, "auto_stop_on_leave_scene", "Reset when leaving the auto-start scene")
local ss = obs.obs_properties_add_list(p, "stream_stop_action", "When streaming stops",
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
obs.obs_property_list_add_string(ss, "Do nothing", "none")
obs.obs_property_list_add_string(ss, "Pause", "pause")
obs.obs_property_list_add_string(ss, "Reset", "reset")
local rs = obs.obs_properties_add_list(p, "record_stop_action", "When recording stops",
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
obs.obs_property_list_add_string(rs, "Do nothing", "none")
obs.obs_property_list_add_string(rs, "Pause", "pause")
obs.obs_property_list_add_string(rs, "Reset", "reset")
obs.obs_properties_add_text(p, "focus_scene_name", "Switch to scene on Focus (optional)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_text(p, "break_scene_name", "Switch to scene on Break (optional)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "mute_mic_on_break", "Mute a mic source during breaks")
obs.obs_properties_add_text(p, "mic_source_name", "Mic source to mute (optional)", obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_bool(p, "persist_state", "Resume the timer after a reload / restart")
obs.obs_properties_add_button(p, "btn_start", "Start", start_pressed)
obs.obs_properties_add_button(p, "btn_toggle", "Pause / Resume", toggle_pause_pressed)
obs.obs_properties_add_button(p, "btn_stop", "Stop", stop_pressed)
obs.obs_properties_add_button(p, "btn_reset", "Reset", reset_pressed)
obs.obs_properties_add_button(p, "btn_skip", "Skip", skip_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", "Pomodoro Pro Timer v"..VERSION, obs.OBS_TEXT_INFO)
return p
end
function script_defaults(s)
obs.obs_data_set_default_string(s, "timer_source_name", "PomodoroTimer")
obs.obs_data_set_default_string(s, "status_source_name", "")
obs.obs_data_set_default_string(s, "focus_message", "Focus Time!")
obs.obs_data_set_default_string(s, "short_break_message", "Short Break!")
obs.obs_data_set_default_string(s, "long_break_message", "Long Break!")
obs.obs_data_set_default_string(s, "paused_message", "Paused")
obs.obs_data_set_default_string(s, "stopped_message", "Timer Stopped")
obs.obs_data_set_default_int(s, "focus_minutes", 25)
obs.obs_data_set_default_int(s, "short_break_minutes", 5)
obs.obs_data_set_default_int(s, "long_break_minutes", 15)
obs.obs_data_set_default_int(s, "sessions_before_long", 4)
obs.obs_data_set_default_int(s, "cycles_before_stop", 0)
obs.obs_data_set_default_bool(s, "manual_advance", false)
obs.obs_data_set_default_bool(s, "show_mode_label", true)
obs.obs_data_set_default_string(s, "label_focus", "FOCUS")
obs.obs_data_set_default_string(s, "label_short_break", "BREAK")
obs.obs_data_set_default_string(s, "label_long_break", "LONG BREAK")
obs.obs_data_set_default_bool(s, "show_session_counter", true)
obs.obs_data_set_default_string(s, "session_label", "Session")
obs.obs_data_set_default_bool(s, "show_end_time", true)
obs.obs_data_set_default_bool(s, "end_time_inline", true)
obs.obs_data_set_default_string(s, "end_time_label", "Ends at")
obs.obs_data_set_default_string(s, "sep_char", "·")
obs.obs_data_set_default_bool(s, "use_24h", true)
obs.obs_data_set_default_string(s, "display_format", "{label}{time}{session}{end}{total}")
obs.obs_data_set_default_int(s, "bar_width", 10)
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, "duration_preset", "custom")
obs.obs_data_set_default_bool(s, "show_total_focus", false)
obs.obs_data_set_default_string(s, "total_focus_label", "Done")
obs.obs_data_set_default_bool(s, "idle_show_focus_duration", true)
obs.obs_data_set_default_bool(s, "hide_when_stopped", false)
obs.obs_data_set_default_string(s, "idle_text", "")
obs.obs_data_set_default_bool(s, "enable_sounds", true)
obs.obs_data_set_default_int(s, "sound_volume", 80)
obs.obs_data_set_default_int(s, "beep_last_seconds", 0)
obs.obs_data_set_default_int(s, "color_focus", color_focus)
obs.obs_data_set_default_int(s, "color_short_break", color_short_break)
obs.obs_data_set_default_int(s, "color_long_break", color_long_break)
obs.obs_data_set_default_int(s, "color_paused", color_paused)
obs.obs_data_set_default_int(s, "color_stopped", color_stopped)
obs.obs_data_set_default_int(s, "color_warning", color_warning)
obs.obs_data_set_default_int(s, "warn_seconds", 0)
obs.obs_data_set_default_string(s, "auto_scene_name", "")
obs.obs_data_set_default_bool(s, "auto_start_on_stream", false)
obs.obs_data_set_default_bool(s, "auto_start_on_record", false)
obs.obs_data_set_default_bool(s, "auto_stop_on_leave_scene", false)
obs.obs_data_set_default_string(s, "stream_stop_action", "none")
obs.obs_data_set_default_string(s, "record_stop_action", "none")
obs.obs_data_set_default_string(s, "focus_scene_name", "")
obs.obs_data_set_default_string(s, "break_scene_name", "")
obs.obs_data_set_default_bool(s, "mute_mic_on_break", false)
obs.obs_data_set_default_string(s, "mic_source_name", "")
obs.obs_data_set_default_bool(s, "persist_state", true)
end
function script_update(s)
timer_source_name = obs.obs_data_get_string(s, "timer_source_name")
status_source_name= obs.obs_data_get_string(s, "status_source_name")
focus_message = obs.obs_data_get_string(s, "focus_message")
short_break_message = obs.obs_data_get_string(s, "short_break_message")
long_break_message = obs.obs_data_get_string(s, "long_break_message")
paused_message = obs.obs_data_get_string(s, "paused_message")
stopped_message = obs.obs_data_get_string(s, "stopped_message")
focus_minutes = math.max(1, obs.obs_data_get_int(s, "focus_minutes"))
short_break_minutes = math.max(1, obs.obs_data_get_int(s, "short_break_minutes"))
long_break_minutes = math.max(1, obs.obs_data_get_int(s, "long_break_minutes"))
sessions_before_long= math.max(1, obs.obs_data_get_int(s, "sessions_before_long"))
cycles_before_stop = math.max(0, obs.obs_data_get_int(s, "cycles_before_stop"))
manual_advance = obs.obs_data_get_bool(s, "manual_advance")
show_mode_label = obs.obs_data_get_bool(s, "show_mode_label")
label_focus = obs.obs_data_get_string(s, "label_focus")
label_short_break = obs.obs_data_get_string(s, "label_short_break")
label_long_break = obs.obs_data_get_string(s, "label_long_break")
show_session_counter = obs.obs_data_get_bool(s, "show_session_counter")
session_label = obs.obs_data_get_string(s, "session_label")
show_end_time = obs.obs_data_get_bool(s, "show_end_time")
end_time_inline = obs.obs_data_get_bool(s, "end_time_inline")
end_time_label = obs.obs_data_get_string(s, "end_time_label")
sep_char = obs.obs_data_get_string(s, "sep_char"); if sep_char == "" then sep_char = "·" end
use_24h = obs.obs_data_get_bool(s, "use_24h")
display_format = obs.obs_data_get_string(s, "display_format")
if display_format == "" then display_format = "{label}{time}{session}{end}{total}" end
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
show_total_focus = obs.obs_data_get_bool(s, "show_total_focus")
total_focus_label = obs.obs_data_get_string(s, "total_focus_label")
idle_show_focus_duration = obs.obs_data_get_bool(s, "idle_show_focus_duration")
hide_when_stopped = obs.obs_data_get_bool(s, "hide_when_stopped")
idle_text = obs.obs_data_get_string(s, "idle_text")
enable_sounds = obs.obs_data_get_bool(s, "enable_sounds")
sound_volume = obs.obs_data_get_int(s, "sound_volume")
sound_focus_file = obs.obs_data_get_string(s, "sound_focus_file")
sound_short_file = obs.obs_data_get_string(s, "sound_short_file")
sound_long_file = obs.obs_data_get_string(s, "sound_long_file")
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")
color_focus = obs.obs_data_get_int(s, "color_focus")
color_short_break = obs.obs_data_get_int(s, "color_short_break")
color_long_break = obs.obs_data_get_int(s, "color_long_break")
color_paused = obs.obs_data_get_int(s, "color_paused")
color_stopped= obs.obs_data_get_int(s, "color_stopped")
color_warning = obs.obs_data_get_int(s, "color_warning")
warn_seconds = math.max(0, obs.obs_data_get_int(s, "warn_seconds"))
auto_scene_name = obs.obs_data_get_string(s, "auto_scene_name")
auto_start_on_stream = obs.obs_data_get_bool(s, "auto_start_on_stream")
auto_start_on_record = obs.obs_data_get_bool(s, "auto_start_on_record")
auto_stop_on_leave_scene = obs.obs_data_get_bool(s, "auto_stop_on_leave_scene")
stream_stop_action = obs.obs_data_get_string(s, "stream_stop_action")
record_stop_action = obs.obs_data_get_string(s, "record_stop_action")
focus_scene_name = obs.obs_data_get_string(s, "focus_scene_name")
break_scene_name = obs.obs_data_get_string(s, "break_scene_name")
mute_mic_on_break = obs.obs_data_get_bool(s, "mute_mic_on_break")
mic_source_name = obs.obs_data_get_string(s, "mic_source_name")
persist_state = obs.obs_data_get_bool(s, "persist_state")
warned_missing = false
rebuild_sound_sources()
push_display()
end
-- ==== Hotkeys ====
local function hk_load(settings, id, name)
local arr = obs.obs_data_get_array(settings, name)
obs.obs_hotkey_load(id, arr)
obs.obs_data_array_release(arr)
end
local function hk_save(settings, id, name)
local arr = obs.obs_hotkey_save(id)
obs.obs_data_set_array(settings, name, arr)
obs.obs_data_array_release(arr)
end
function script_load(settings)
hk_start = obs.obs_hotkey_register_frontend("pomo_start", "Pomodoro Start", function(pressed) if pressed then start_pressed(true) end end)
hk_pause = obs.obs_hotkey_register_frontend("pomo_pause", "Pomodoro Pause", function(pressed) if pressed then pause_pressed(true) end end)
hk_resume = obs.obs_hotkey_register_frontend("pomo_resume", "Pomodoro Resume", function(pressed) if pressed then resume_pressed(true) end end)
hk_toggle = obs.obs_hotkey_register_frontend("pomo_toggle", "Pomodoro Pause/Resume", function(pressed) if pressed then toggle_pause_pressed(true) end end)
hk_stop = obs.obs_hotkey_register_frontend("pomo_stop", "Pomodoro Stop", function(pressed) if pressed then stop_pressed(true) end end)
hk_reset = obs.obs_hotkey_register_frontend("pomo_reset", "Pomodoro Reset", function(pressed) if pressed then reset_pressed(true) end end)
hk_skip = obs.obs_hotkey_register_frontend("pomo_skip", "Pomodoro Skip", function(pressed) if pressed then skip_pressed(true) end end)
hk_addmin = obs.obs_hotkey_register_frontend("pomo_add_min", "Pomodoro +1 min", function(pressed) if pressed then add_minute_pressed(true) end end)
hk_submin = obs.obs_hotkey_register_frontend("pomo_sub_min", "Pomodoro -1 min", function(pressed) if pressed then sub_minute_pressed(true) end end)
hk_load(settings, hk_start, "pomo_start")
hk_load(settings, hk_pause, "pomo_pause")
hk_load(settings, hk_resume, "pomo_resume")
hk_load(settings, hk_toggle, "pomo_toggle")
hk_load(settings, hk_stop, "pomo_stop")
hk_load(settings, hk_reset, "pomo_reset")
hk_load(settings, hk_skip, "pomo_skip")
hk_load(settings, hk_addmin, "pomo_add_min")
hk_load(settings, hk_submin, "pomo_sub_min")
obs.obs_frontend_add_event_callback(function(ev)
if ev == obs.OBS_FRONTEND_EVENT_SCENE_CHANGED then
if auto_scene_name ~= "" then
local cur = obs.obs_frontend_get_current_scene()
if cur then
local nm = obs.obs_source_get_name(cur)
if nm == auto_scene_name and not timer_running then
start_pressed(true)
elseif nm ~= auto_scene_name and timer_running and auto_stop_on_leave_scene then
reset_pressed(true)
end
obs.obs_source_release(cur)
end
end
elseif ev == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED then
if auto_start_on_stream and not timer_running then start_pressed(true) end
elseif ev == obs.OBS_FRONTEND_EVENT_RECORDING_STARTED then
if auto_start_on_record and not timer_running then start_pressed(true) end
elseif ev == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED then
apply_stop_action(stream_stop_action)
elseif ev == obs.OBS_FRONTEND_EVENT_RECORDING_STOPPED then
apply_stop_action(record_stop_action)
end
end)
-- Resume a running timer after a script reload or an OBS restart. The
-- countdown is anchored to an absolute timestamp, so the remaining time
-- stays correct even across the downtime.
if obs.obs_data_get_bool(settings, "persist_state") then
local m = obs.obs_data_get_string(settings, "st_mode")
if m ~= "" then
mode = m
time_left = obs.obs_data_get_int(settings, "st_time_left")
segment_end_ts = obs.obs_data_get_int(settings, "st_end_ts")
segment_total = obs.obs_data_get_int(settings, "st_segment_total")
session_count = obs.obs_data_get_int(settings, "st_session")
cycles_completed = obs.obs_data_get_int(settings, "st_cycles")
total_focus_completed = obs.obs_data_get_int(settings, "st_total")
timer_running = obs.obs_data_get_bool(settings, "st_running")
awaiting_advance = obs.obs_data_get_bool(settings, "st_awaiting")
if timer_running and not awaiting_advance
and (mode == "focus" or mode == "short_break" or mode == "long_break") then
start_ticking()
end
end
end
end
function script_save(settings)
hk_save(settings, hk_start, "pomo_start")
hk_save(settings, hk_pause, "pomo_pause")
hk_save(settings, hk_resume, "pomo_resume")
hk_save(settings, hk_toggle, "pomo_toggle")
hk_save(settings, hk_stop, "pomo_stop")
hk_save(settings, hk_reset, "pomo_reset")
hk_save(settings, hk_skip, "pomo_skip")
hk_save(settings, hk_addmin, "pomo_add_min")
hk_save(settings, hk_submin, "pomo_sub_min")
if persist_state then
obs.obs_data_set_string(settings, "st_mode", mode)
obs.obs_data_set_int(settings, "st_time_left", time_left)
obs.obs_data_set_int(settings, "st_end_ts", segment_end_ts)
obs.obs_data_set_int(settings, "st_segment_total", segment_total)
obs.obs_data_set_int(settings, "st_session", session_count)
obs.obs_data_set_int(settings, "st_cycles", cycles_completed)
obs.obs_data_set_int(settings, "st_total", total_focus_completed)
obs.obs_data_set_bool(settings, "st_running", timer_running)
obs.obs_data_set_bool(settings, "st_awaiting", awaiting_advance)
end
end
function script_description()
return "Pomodoro Pro Timer v" .. VERSION .. "" ..
"
Shows a Pomodoro countdown as an overlay. Add a Text source, name it " ..
"PomodoroTimer, put that name in Timer text source below, then press Start." ..
"
Focus / break cycles, session counter, end time, colors, sounds, hotkeys, " ..
"auto-start on scene / stream / recording, progress bar via the {bar} token, and " ..
"language presets. Assign hotkeys in Settings > Hotkeys."
end
function script_unload()
stop_ticking()
release_sound_sources()
end