-- OBS-MonitorDefaults 3.0.0
-- Author: haliluya.net
-- SPDX-License-Identifier: GPL-2.0-or-later
local obs = obslua
local band = require("bit").band
-- Select UI text once; source callbacks do not perform translation lookups.
local text = ({
["zh-CN"] = {
version = "版本:",
author = "作者", refresh = "刷新来源类型", keep = "保留 OBS 默认",
off = "关闭监听", only = "仅监听(输出静音)", both = "监听并输出",
missing = "未找到某个类型?先添加该类型的来源,再刷新列表。",
startup = "自动全屏", program = "输出全屏", multiview = "多视图全屏",
disabled = "不启用", display = "显示器 %d",
datetime = "日期时间", text_source = "文本源", format = "显示格式",
},
["zh-TW"] = {
version = "版本:",
author = "作者", refresh = "重新整理來源類型", keep = "保留 OBS 預設",
off = "關閉監聽", only = "僅監聽(輸出靜音)", both = "監聽並輸出",
missing = "找不到某個類型?先新增該類型的來源,再重新整理清單。",
startup = "自動全螢幕", program = "輸出全螢幕", multiview = "多視圖全螢幕",
disabled = "不啟用", display = "顯示器 %d",
datetime = "日期時間", text_source = "文字來源", format = "顯示格式",
},
})[obs.obs_get_locale()] or {
version = "Version: ",
author = "Author", refresh = "Refresh Source Types", keep = "Keep OBS Default",
off = "Monitor Off", only = "Monitor Only (mute output)", both = "Monitor and Output",
missing = "Add a source of the missing type, then refresh the list.",
startup = "Auto Fullscreen", program = "Program Output", multiview = "Multiview Output",
disabled = "Disabled", display = "Display %d",
datetime = "Date and Time", text_source = "Text Source", format = "Display Format",
}
local types, modes, seen, pending = {}, {}, {}, {}
local media = {}
local running, ready, handler = false, false, nil
local ffi, native
local apply_pending
local startup_enabled, startup_done, startup_pending = false, false, false
local projectors = {
{key = "projector_program", kind = "StudioProgram", label = text.program, monitor = 0},
{key = "projector_multiview", kind = "Multiview", label = text.multiview, monitor = 0},
}
-- Date/time behavior adapted from Ragowit's date-and-time.lua.
local clock = {enabled = false, source = "", format = "", valid = false, active = false}
local clock_tick
local function stop_clock()
if clock.active then obs.timer_remove(clock_tick) end
clock.active, clock.last = false, nil
end
local function is_text_source(source)
if source == nil then return false end
local id = obs.obs_source_get_unversioned_id(source)
return id == "text_gdiplus" or id == "text_ft2_source"
end
clock_tick = function()
if not running or not ready or not clock.enabled or not clock.valid or clock.source == "" then
stop_clock()
return
end
local source = obs.obs_get_source_by_uuid(clock.source)
if source == nil then stop_clock(); return end
if obs.obs_source_removed(source) or not is_text_source(source) or not obs.obs_source_showing(source) then
obs.obs_source_release(source)
stop_clock()
return
end
local ok, value = pcall(os.date, clock.format)
if not ok or type(value) ~= "string" then
obs.obs_source_release(source)
clock.valid = false
stop_clock()
obs.script_log(obs.LOG_WARNING, "Invalid date/time format.")
return
end
if value ~= clock.last then
local settings = obs.obs_data_create()
obs.obs_data_set_string(settings, "text", value)
obs.obs_data_set_bool(settings,
obs.obs_source_get_unversioned_id(source) == "text_gdiplus" and "read_from_file" or "from_file", false)
obs.obs_source_update(source, settings)
obs.obs_data_release(settings)
clock.last = value
end
obs.obs_source_release(source)
if not clock.active then
clock.active = true
obs.timer_add(clock_tick, 1000)
end
end
local function clock_changed(cd)
if not running or not ready or not clock.enabled then return end
local source = obs.calldata_source(cd, "source")
if source ~= nil and obs.obs_source_get_uuid(source) == clock.source then clock_tick() end
end
local function clock_removed(cd)
local source = obs.calldata_source(cd, "source")
if source ~= nil and obs.obs_source_get_uuid(source) == clock.source then stop_clock() end
end
local function update_clock(settings)
local enabled = obs.obs_data_get_bool(settings, "datetime")
local source = obs.obs_data_get_string(settings, "datetime_source")
local format = obs.obs_data_get_string(settings, "datetime_format")
if enabled == clock.enabled and source == clock.source and format == clock.format then return end
stop_clock()
clock.enabled, clock.source, clock.format = enabled, source, format
-- Limit directives to the portable strftime subset before calling the system CRT.
clock.valid = #format <= 256 and format ~= "*t" and format ~= "!*t" and
not format:gsub("%%[aAbBcdHIjmMpSUwWxXyYzZ%%]", ""):find("%", 1, true)
if enabled and not clock.valid then obs.script_log(obs.LOG_WARNING, "Invalid date/time format.") end
clock_tick()
end
-- FFI enumerates type IDs unavailable through the Lua bindings.
pcall(function()
ffi = require("ffi")
ffi.cdef [[bool obs_enum_input_types2(size_t, const char **, const char **);]]
for _, name in ipairs({"", "obs", "@rpath/libobs.framework/Versions/A/libobs"}) do
local ok, library = pcall(function()
local library = name == "" and ffi.C or ffi.load(name)
assert(library.obs_enum_input_types2)
return library
end)
if ok then native = library; break end
end
end)
-- Read and edit only metadata; all patches keep the original byte lengths.
local open_file = io.open
pcall(function()
if ffi == nil or ffi.os ~= "Windows" then return end
ffi.cdef [[
int __stdcall MultiByteToWideChar(unsigned int, unsigned long, const char *, int, wchar_t *, int);
void *_wfopen(const wchar_t *, const wchar_t *);
size_t fread(void *, size_t, size_t, void *);
size_t fwrite(const void *, size_t, size_t, void *);
int _fseeki64(void *, int64_t, int);
int64_t _ftelli64(void *);
int fflush(void *);
int fclose(void *);
]]
local kernel, crt = ffi.load("kernel32"), ffi.load("ucrtbase")
local whence = {set = 0, cur = 1, ["end"] = 2}
local methods = {}
function methods:read(count)
if count == 0 then return "" end
local buffer = ffi.new("uint8_t[?]", count)
local size = tonumber(crt.fread(buffer, 1, count, self.handle))
if size > 0 then return ffi.string(buffer, size) end
end
function methods:seek(origin, offset)
if crt._fseeki64(self.handle, offset or 0, whence[origin or "cur"]) == 0 then
local position = tonumber(crt._ftelli64(self.handle))
if position >= 0 then return position end
end
end
function methods:write(data)
if tonumber(crt.fwrite(data, 1, #data, self.handle)) == #data then return self end
end
function methods:flush() return crt.fflush(self.handle) == 0 end
function methods:close()
local result = crt.fclose(self.handle) == 0
self.handle = nil
return result
end
open_file = function(path, mode)
local wide = ffi.new("wchar_t[?]", #path + 1)
if kernel.MultiByteToWideChar(65001, 8, path, #path, wide, #path) == 0 then return end
local flags = ffi.new("wchar_t[?]", #mode + 1)
for index = 1, #mode do flags[index - 1] = mode:byte(index) end
local file = crt._wfopen(wide, flags)
if file ~= nil then return setmetatable({handle = file}, {__index = methods}) end
end
end)
local metadata_limit = 16 * 1024 * 1024
local function number(data, base, reverse)
local value = 0
for index = 1, #data do
local byte = data:byte(reverse and #data - index + 1 or index)
if base == 128 and byte >= 128 then return end
value = value * base + byte
end
return value
end
local function read_at(file, offset, count)
if file:seek("set", offset) == nil then return end
local data = file:read(count)
if data ~= nil and #data == count then return data end
end
local function add_patch(patches, offset, original, replacement)
assert(#original == #replacement, "metadata length changed")
patches.bytes = (patches.bytes or 0) + #original
assert(patches.bytes <= metadata_limit, "metadata exceeds 16 MiB")
patches[#patches + 1] = {offset = offset, original = original, replacement = replacement}
end
local function id3_patches(file, offset, limit, patches)
local header = assert(read_at(file, offset, 10), "truncated ID3 header")
assert(header:sub(1, 3) == "ID3", "missing ID3 header")
local version, revision, flags = header:byte(4, 6)
assert(version >= 2 and version <= 4 and revision == 0, "unsupported ID3 version")
-- Skip whole-tag unsynchronisation, compression, CRCs and footers.
assert(flags == 0 or (version >= 3 and flags == 32), "unsupported ID3 flags")
local size = assert(number(header:sub(7, 10), 128), "invalid ID3 size")
patches.inspected = (patches.inspected or 0) + size
assert(patches.inspected <= metadata_limit and offset + 10 + size <= limit, "invalid ID3 bounds")
local body = assert(read_at(file, offset + 10, size), "truncated ID3 tag")
local kept, removed, position = {}, 0, 1
local width = version == 2 and 6 or 10
while position <= #body do
if body:byte(position) == 0 then
assert(not body:sub(position):find("[^%z]"), "invalid ID3 padding")
kept[#kept + 1] = body:sub(position)
break
end
assert(position + width - 1 <= #body, "truncated ID3 frame")
local id_width = version == 2 and 3 or 4
local id = body:sub(position, position + id_width - 1)
assert(id:match("^[A-Z0-9]+$"), "invalid ID3 frame ID")
local length = assert(number(body:sub(position + id_width, position + width - (version == 2 and 1 or 3)),
version == 4 and 128 or 256), "invalid ID3 frame size")
local finish = position + width + length - 1
assert(length > 0 and finish <= #body, "invalid ID3 frame bounds")
if id == "APIC" or (version == 2 and id == "PIC") then
removed = removed + width + length
else
kept[#kept + 1] = body:sub(position, finish)
end
position = finish + 1
end
if removed > 0 then add_patch(patches, offset + 10, body, table.concat(kept) .. string.rep("\0", removed)) end
return offset + 10 + size
end
local function flac_patches(file, size, patches)
assert(read_at(file, 0, 4) == "fLaC", "invalid FLAC signature")
local offset = 4
for index = 1, 4096 do
local header = assert(read_at(file, offset, 4), "truncated FLAC metadata")
local kind = band(header:byte(1), 127)
local length = number(header:sub(2), 256)
local finish = offset + 4 + length
assert(kind < 127 and finish <= size and finish <= metadata_limit, "invalid FLAC metadata bounds")
assert(index ~= 1 or (kind == 0 and length == 34), "invalid FLAC STREAMINFO")
assert(index == 1 or kind ~= 0, "duplicate FLAC STREAMINFO")
if kind == 6 then
assert(length >= 32, "invalid FLAC picture block")
local picture = assert(read_at(file, offset + 4, length), "truncated FLAC picture")
add_patch(patches, offset, header .. picture,
string.char(band(header:byte(1), 128) + 1) .. header:sub(2) .. string.rep("\0", length))
end
offset = finish
if header:byte(1) >= 128 then
local sync = assert(read_at(file, offset, 2), "missing FLAC audio")
assert(sync:byte(1) == 255 and band(sync:byte(2), 254) == 248, "invalid FLAC audio signature")
return
end
end
error("too many FLAC metadata blocks")
end
local function chunk_patches(file, size, extension, patches)
local header = assert(read_at(file, 0, 12), "truncated audio container")
local wave = extension == "wav"
assert(wave and header:sub(1, 4) == "RIFF" and header:sub(9) == "WAVE" or
not wave and header:sub(1, 4) == "FORM" and (header:sub(9) == "AIFF" or header:sub(9) == "AIFC"),
"unsupported audio container")
local limit = number(header:sub(5, 8), 256, wave) + 8
assert(limit <= size and limit >= 12, "invalid audio container size")
local offset, audio, format, count = 12, false, false, 0
while offset < limit do
count = count + 1
assert(count <= 4096, "too many audio chunks")
local chunk = assert(read_at(file, offset, 8), "truncated audio chunk")
local id, length = chunk:sub(1, 4), number(chunk:sub(5), 256, wave)
local finish = offset + 8 + length
assert(finish <= limit, "invalid audio chunk bounds")
if id:lower() == "id3 " then id3_patches(file, offset + 8, finish, patches) end
if id == (wave and "data" or "SSND") and length > 0 then audio = true end
if id == (wave and "fmt " or "COMM") then format = true end
offset = finish + length % 2
end
assert(audio and format and offset == limit, "invalid audio chunks")
end
local function m4a_patches(file, size, patches)
local audio, video, media_data, boxes = false, false, false, 0
local function walk(start, limit, parent, depth)
assert(depth <= 6, "MP4 nesting too deep")
local offset = start
while offset < limit do
boxes = boxes + 1
assert(boxes <= 65536, "too many MP4 boxes")
local header = assert(read_at(file, offset, 8), "truncated MP4 box")
local length, kind, width = number(header:sub(1, 4), 256), header:sub(5), 8
if length == 1 then
length, width = number(assert(read_at(file, offset + 8, 8), "truncated MP4 large size"), 256), 16
elseif length == 0 then length = limit - offset end
local finish = offset + length
assert(length >= width and length <= 9007199254740991 and finish <= limit, "invalid MP4 bounds")
local content = offset + width
if parent == "root" and kind == "mdat" and length > width then media_data = true end
if parent == "mdia" and kind == "hdlr" then
local handler = assert(read_at(file, content, 12), "truncated MP4 handler"):sub(9)
if handler == "soun" then audio = true end
if handler == "vide" then video = true end
elseif parent == "ilst" and kind == "covr" then
assert(length <= metadata_limit, "MP4 cover exceeds 16 MiB")
local original = assert(read_at(file, offset, length), "truncated MP4 cover")
add_patch(patches, offset, original,
original:sub(1, 4) .. "free" .. original:sub(9, width) .. string.rep("\0", length - width))
elseif (parent == "root" and kind == "moov") or (parent == "moov" and (kind == "trak" or kind == "udta" or kind == "meta")) or
(parent == "trak" and (kind == "mdia" or kind == "udta")) or
(parent == "udta" and kind == "meta") or (parent == "meta" and kind == "ilst") then
if kind == "meta" then
assert(read_at(file, content, 4) == "\0\0\0\0", "unsupported MP4 metadata version")
content = content + 4
end
walk(content, finish, kind, depth + 1)
end
offset = finish
end
assert(offset == limit, "invalid MP4 box alignment")
end
local first = assert(read_at(file, 0, 8), "truncated M4A file")
assert(first:sub(5) == "ftyp", "unsupported M4A signature")
walk(0, size, "root", 0)
assert(audio and not video and media_data, "M4A must contain audio without video tracks")
end
local function remove_cover(path)
local extension = path:lower():match("%.([a-z0-9]+)$")
if extension ~= "mp3" and extension ~= "flac" and extension ~= "wav" and
extension ~= "aif" and extension ~= "aiff" and extension ~= "m4a" then return false end
if path:find("%z") then return nil, "invalid filename" end
local file = open_file(path, "rb")
if file == nil then return nil, "cannot read file" end
local size = file:seek("end")
local patches = {}
local ok, reason = pcall(function()
assert(size ~= nil, "cannot read file size")
if extension == "flac" then flac_patches(file, size, patches)
elseif extension == "m4a" then m4a_patches(file, size, patches)
elseif extension == "mp3" then
local offset = 0
if read_at(file, 0, 3) == "ID3" then offset = id3_patches(file, 0, size, patches) end
local frame = assert(read_at(file, offset, 4), "missing MP3 audio")
local a, b, c = frame:byte(1, 3)
assert(a == 255 and band(b, 224) == 224 and band(b, 24) ~= 8 and band(b, 6) == 2 and
band(c, 240) ~= 240 and band(c, 12) ~= 12, "invalid MP3 audio signature")
else chunk_patches(file, size, extension, patches) end
end)
file:close()
if not ok then return nil, tostring(reason):gsub("^.-:%d+: ", "") end
if #patches == 0 then return false end
file = open_file(path, "r+b")
if file == nil then return nil, "file is locked or read-only" end
-- Recheck every region before writing; restore metadata if a write fails.
ok = file:seek("end") == size
for _, patch in ipairs(patches) do
if read_at(file, patch.offset, #patch.original) ~= patch.original then ok = false; break end
end
if not ok then file:close(); return nil, "file changed during inspection" end
local function write_all(field)
for _, patch in ipairs(patches) do
if file:seek("set", patch.offset) == nil or not file:write(patch[field]) then return false end
end
if not file:flush() then return false end
for _, patch in ipairs(patches) do
if read_at(file, patch.offset, #patch[field]) ~= patch[field] then return false end
end
return true
end
local written, result = pcall(write_all, "replacement")
local restored = true
if not written or not result then
local success, value = pcall(write_all, "original")
restored = success and value
end
file:close()
if not written or not result then
return nil, restored and "write failed; metadata restored" or "write and recovery failed; check the file"
end
return true
end
local function set_media_path(source, path)
local settings = obs.obs_data_create()
obs.obs_data_set_string(settings, "local_file", path)
obs.obs_source_update(source, settings)
obs.obs_data_release(settings)
end
local function restore_media(source, entry)
if entry.reload == nil then return end
local path = entry.reload
entry.reload, entry.closed = nil, nil
local settings = obs.obs_source_get_settings(source)
local restore = obs.obs_data_get_bool(settings, "is_local_file") and obs.obs_data_get_string(settings, "local_file") == ""
obs.obs_data_release(settings)
if restore then set_media_path(source, path) end
return restore
end
local function update_media(source, entry)
if entry.reload ~= nil then
if not entry.closed then return end
if restore_media(source, entry) then return end
end
if obs.obs_source_get_monitoring_type(source) ~= obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT then return end
local settings = obs.obs_source_get_settings(source)
local path = obs.obs_data_get_bool(settings, "is_local_file") and obs.obs_data_get_string(settings, "local_file") or ""
obs.obs_data_release(settings)
if path == "" or path == entry.path then return end
entry.path = path
local changed, reason = remove_cover(path)
if changed then
-- A normal media restart reuses the decoder and its cached cover.
entry.reload = path
set_media_path(source, "")
elseif reason ~= nil then
obs.script_log(obs.LOG_WARNING, "Cover removal skipped: " .. reason .. " (" .. path .. ")")
end
end
local function is_audio(source)
return source ~= nil and obs.obs_source_get_type(source) == obs.OBS_SOURCE_TYPE_INPUT and
band(obs.obs_source_get_output_flags(source), obs.OBS_SOURCE_AUDIO) ~= 0
end
local function queue_source(source, mode)
if not running or not ready then return end
local uuid = obs.obs_source_get_uuid(source)
local entry = pending[uuid]
if entry == nil then
local weak = obs.obs_source_get_weak_source(source)
if weak == nil then return end
local first = next(pending) == nil
entry = {weak = weak}
pending[uuid] = entry
if first then obs.timer_add(apply_pending, 100) end
end
if mode ~= nil then
entry.mode, entry.initial = mode, obs.obs_source_get_monitoring_type(source)
end
end
local function media_changed(cd)
local source = obs.calldata_source(cd, "source")
if source ~= nil and media[obs.obs_source_get_uuid(source)] ~= nil then queue_source(source) end
end
local function media_updated(cd)
clock_changed(cd)
local source = obs.calldata_source(cd, "source")
local entry = source ~= nil and media[obs.obs_source_get_uuid(source)] or nil
if entry ~= nil then
entry.path = nil
if entry.reload ~= nil then entry.closed = true end
queue_source(source)
end
end
local function disconnect_media(entry)
obs.signal_handler_disconnect(entry.handler, "audio_monitoring", media_changed)
obs.signal_handler_disconnect(entry.handler, "update", media_updated)
end
local function watch_media(source, uuid)
if obs.obs_source_get_unversioned_id(source) ~= "ffmpeg_source" then return end
local weak = obs.obs_source_get_weak_source(source)
if weak == nil then return end
local signals = obs.obs_source_get_signal_handler(source)
media[uuid] = {weak = weak, handler = signals}
obs.signal_handler_connect(signals, "audio_monitoring", media_changed)
obs.signal_handler_connect(signals, "update", media_updated)
queue_source(source)
end
local function media_destroyed(cd)
clock_removed(cd)
local source = obs.calldata_source(cd, "source")
if source == nil then return end
local uuid = obs.obs_source_get_uuid(source)
local entry = media[uuid]
if entry == nil then return end
disconnect_media(entry)
obs.obs_weak_source_release(entry.weak)
media[uuid] = nil
end
local function clear_media()
for _, entry in pairs(media) do
disconnect_media(entry)
local source = obs.obs_weak_source_get_source(entry.weak)
if source ~= nil then
restore_media(source, entry)
obs.obs_source_release(source)
end
obs.obs_weak_source_release(entry.weak)
end
media = {}
end
local function add_type(id, registered)
types[id] = obs.obs_source_get_display_name(registered) or id
end
local function discover_types()
local text_sources = {}
if native ~= nil then
local id, family = ffi.new("const char *[1]"), ffi.new("const char *[1]")
local index = 0
while native.obs_enum_input_types2(index, id, family) do
local registered = ffi.string(id[0])
local flags = obs.obs_get_source_output_flags(registered)
if band(flags, obs.OBS_SOURCE_AUDIO) ~= 0 and
band(flags, obs.OBS_SOURCE_CAP_DISABLED + obs.OBS_SOURCE_DEPRECATED) == 0 then
add_type(ffi.string(family[0]), registered)
end
index = index + 1
end
else
for _, id in ipairs({"ffmpeg_source", "vlc_source", "browser_source", "wasapi_input_capture",
"wasapi_output_capture", "wasapi_process_output_capture", "coreaudio_input_capture",
"coreaudio_output_capture", "dshow_input", "decklink-input"}) do
if band(obs.obs_get_source_output_flags(id), obs.OBS_SOURCE_AUDIO) ~= 0 then add_type(id, id) end
end
end
local sources = obs.obs_enum_sources()
if sources == nil then return text_sources end
for _, source in ipairs(sources) do
if is_audio(source) then
add_type(obs.obs_source_get_unversioned_id(source), obs.obs_source_get_id(source))
elseif is_text_source(source) and not obs.obs_source_removed(source) then
text_sources[#text_sources + 1] = {uuid = obs.obs_source_get_uuid(source), label = obs.obs_source_get_name(source)}
end
end
obs.source_list_release(sources)
return text_sources
end
local function remember_existing()
local sources = obs.obs_enum_sources()
if sources == nil then return end
for _, source in ipairs(sources) do
if is_audio(source) then
seen[obs.obs_source_get_uuid(source)] = true
end
end
obs.source_list_release(sources)
end
apply_pending = function()
obs.timer_remove(apply_pending)
local batch = pending
pending = {}
for uuid, entry in pairs(batch) do
local source = obs.obs_weak_source_get_source(entry.weak)
if source ~= nil then
if running and ready and not obs.obs_source_removed(source) then
if entry.mode ~= nil then
local current = obs.obs_source_get_monitoring_type(source)
if current == entry.initial or current == obs.OBS_MONITORING_TYPE_MONITOR_ONLY then
obs.obs_source_set_monitoring_type(source, entry.mode)
end
end
if media[uuid] ~= nil then update_media(source, media[uuid]) end
end
obs.obs_source_release(source)
end
obs.obs_weak_source_release(entry.weak)
end
end
local function cancel_pending()
obs.timer_remove(apply_pending)
for _, entry in pairs(pending) do obs.obs_weak_source_release(entry.weak) end
pending = {}
end
local wait_for_scene
local function start_applying()
obs.timer_remove(wait_for_scene)
if not running then return end
remember_existing()
ready = true
clock_tick()
end
wait_for_scene = function()
if not running then obs.timer_remove(wait_for_scene); return end
local scene = obs.obs_frontend_get_current_scene()
if scene ~= nil then
obs.obs_source_release(scene)
start_applying()
end
end
local function source_created(cd)
if not running or not ready then return end
clock_changed(cd)
local source = obs.calldata_source(cd, "source") -- Borrowed reference.
if not is_audio(source) then return end
local uuid = obs.obs_source_get_uuid(source)
if seen[uuid] then return end
seen[uuid] = true
watch_media(source, uuid)
local mode = modes[obs.obs_source_get_unversioned_id(source)]
if mode == nil then return end
if band(obs.obs_source_get_output_flags(source), obs.OBS_SOURCE_MONITOR_BY_DEFAULT) == 0 then
obs.obs_source_set_monitoring_type(source, mode)
return
end
-- OBS sets native monitoring defaults after creation; defer this batch once.
queue_source(source, mode)
end
local function apply_projector_settings()
local config = obs.obs_frontend_get_user_config() -- Borrowed reference.
if config == nil then
obs.script_log(obs.LOG_WARNING, "Projector settings unavailable.")
return
end
local changed = false
for key, value in pairs({HideProjectorCursor = true, ProjectorAlwaysOnTop = true,
SaveProjectors = false, CloseExistingProjectors = true}) do
if obs.config_get_bool(config, "BasicWindow", key) ~= value then
obs.config_set_bool(config, "BasicWindow", key, value)
changed = true
end
end
if changed and obs.config_save_safe(config, "tmp", nil) ~= 0 then
obs.script_log(obs.LOG_WARNING, "Could not save projector settings; changes apply to this session.")
end
end
local function open_startup_projectors()
obs.timer_remove(open_startup_projectors)
if not startup_pending then return end
startup_pending = false
if not running or not ready or not startup_enabled then return end
for _, output in ipairs(projectors) do
if output.monitor > 0 then
obs.obs_frontend_open_projector(output.kind, output.monitor - 1, "", "")
end
end
end
local function cancel_startup_projectors()
startup_pending = false
obs.timer_remove(open_startup_projectors)
end
local function frontend_event(event)
if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING or
event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED then
start_applying()
if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING and running and not startup_done then
startup_done = true
if startup_enabled and (projectors[1].monitor > 0 or projectors[2].monitor > 0) then
startup_pending = true
obs.timer_add(open_startup_projectors, 2000)
end
end
elseif event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING or
event == obs.OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP or
event == obs.OBS_FRONTEND_EVENT_SCRIPTING_SHUTDOWN or event == obs.OBS_FRONTEND_EVENT_EXIT then
ready = false
stop_clock()
cancel_startup_projectors()
obs.timer_remove(wait_for_scene)
cancel_pending()
clear_media()
end
end
function script_description()
return "OBS-MonitorDefaults
" .. text.version .. "3.0.0
" .. text.author .. ": haliluya.net"
end
local source_signals = {
source_create = source_created, source_destroy = media_destroyed, source_load = media_updated,
source_show = clock_changed, source_hide = clock_changed, source_remove = clock_removed,
}
local function refresh_types() return true end
function script_properties()
local text_sources = discover_types()
local properties = obs.obs_properties_create()
obs.obs_properties_add_button(properties, "refresh_types", text.refresh, refresh_types)
local ordered = {}
for id, label in pairs(types) do ordered[#ordered + 1] = {id = id, label = label} end
table.sort(ordered, function(a, b)
if a.label == b.label then return a.id < b.id end
return a.label < b.label
end)
for _, entry in ipairs(ordered) do
local choice = obs.obs_properties_add_list(properties, "type_" .. entry.id, entry.label,
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
obs.obs_property_list_add_int(choice, text.keep, 0)
obs.obs_property_list_add_int(choice, text.off, obs.OBS_MONITORING_TYPE_NONE + 1)
obs.obs_property_list_add_int(choice, text.only, obs.OBS_MONITORING_TYPE_MONITOR_ONLY + 1)
obs.obs_property_list_add_int(choice, text.both, obs.OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT + 1)
end
if native == nil then
obs.obs_properties_add_text(properties, "type_discovery", text.missing,
obs.OBS_TEXT_INFO)
end
local startup = obs.obs_properties_create()
for _, output in ipairs(projectors) do
local choice = obs.obs_properties_add_list(startup, output.key, output.label,
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_INT)
obs.obs_property_list_add_int(choice, text.disabled, 0)
for monitor = 1, 10 do
obs.obs_property_list_add_int(choice, string.format(text.display, monitor), monitor)
end
end
obs.obs_properties_add_group(properties, "startup_projectors", text.startup, obs.OBS_GROUP_CHECKABLE, startup)
local datetime = obs.obs_properties_create()
local source = obs.obs_properties_add_list(datetime, "datetime_source", text.text_source,
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
obs.obs_property_list_add_string(source, text.disabled, "")
table.sort(text_sources, function(a, b) return a.label < b.label end)
for _, entry in ipairs(text_sources) do obs.obs_property_list_add_string(source, entry.label, entry.uuid) end
obs.obs_properties_add_text(datetime, "datetime_format", text.format, obs.OBS_TEXT_DEFAULT)
obs.obs_properties_add_group(properties, "datetime", text.datetime, obs.OBS_GROUP_CHECKABLE, datetime)
return properties
end
function script_defaults(settings)
obs.obs_data_set_default_string(settings, "datetime_format", "%Y-%m-%d %H:%M:%S")
end
function script_update(settings)
local enabled = obs.obs_data_get_bool(settings, "startup_projectors")
if enabled and (not startup_enabled or not running) then apply_projector_settings() end
startup_enabled = enabled
if startup_pending and not startup_enabled then cancel_startup_projectors() end
for _, output in ipairs(projectors) do
local monitor = obs.obs_data_get_int(settings, output.key)
monitor = monitor >= 1 and monitor <= 10 and monitor or 0
if startup_pending and output.monitor ~= monitor then cancel_startup_projectors() end
output.monitor = monitor
end
if next(types) == nil then discover_types() end
modes = {}
for id in pairs(types) do
local selected = obs.obs_data_get_int(settings, "type_" .. id)
if selected >= 1 and selected <= 3 then modes[id] = selected - 1 end
end
update_clock(settings)
end
function script_load(settings)
discover_types()
script_update(settings)
running, ready, seen, startup_done, startup_pending = true, false, {}, false, false
handler = obs.obs_get_signal_handler()
for signal, callback in pairs(source_signals) do obs.signal_handler_connect(handler, signal, callback) end
obs.obs_frontend_add_event_callback(frontend_event)
local scene = obs.obs_frontend_get_current_scene()
if scene ~= nil then
obs.obs_source_release(scene)
start_applying()
else
obs.timer_add(wait_for_scene, 100)
end
end
function script_unload()
running, ready = false, false
stop_clock()
cancel_startup_projectors()
obs.timer_remove(wait_for_scene)
cancel_pending()
clear_media()
if handler ~= nil then
for signal, callback in pairs(source_signals) do obs.signal_handler_disconnect(handler, signal, callback) end
handler = nil
end
obs.obs_frontend_remove_event_callback(frontend_event)
seen = {}
end