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