summaryrefslogtreecommitdiffstats
path: root/player/lua/auto_profiles.lua
blob: a0f580298b6da80f32c4fa731a4ae1c3aa4f95d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
-- Note: anything global is accessible by profile condition expressions.

local utils = require 'mp.utils'
local msg = require 'mp.msg'

local profiles = {}
local watched_properties = {}       -- indexed by property name (used as a set)
local cached_properties = {}        -- property name -> last known raw value
local properties_to_profiles = {}   -- property name -> set of profiles using it
local have_dirty_profiles = false   -- at least one profile is marked dirty
local pending_hooks = {}            -- as set (keys only, meaningless values)

-- Used during evaluation of the profile condition, and should contain the
-- profile the condition is evaluated for.
local current_profile = nil

-- Cached set of all top-level mpv properities. Only used for extra validation.
local property_set = {}
for _, property in pairs(mp.get_property_native("property-list")) do
    property_set[property] = true
end

local function evaluate(profile)
    msg.verbose("Re-evaluating auto profile " .. profile.name)

    current_profile = profile
    local status, res = pcall(profile.cond)
    current_profile = nil

    if not status then
        -- errors can be "normal", e.g. in case properties are unavailable
        msg.verbose("Profile condition error on evaluating: " .. res)
        res = false
    end
    res = not not res
    if res ~= profile.status then
        if res == true then
            msg.info("Applying auto profile: " .. profile.name)
            mp.commandv("apply-profile", profile.name)
        elseif profile.status == true and profile.has_restore_opt then
            msg.info("Restoring profile: " .. profile.name)
            mp.commandv("apply-profile", profile.name, "restore")
        end
    end
    profile.status = res
    profile.dirty = false
end

local function on_property_change(name, val)
    cached_properties[name] = val
    -- Mark all profiles reading this property as dirty, so they get re-evaluated
    -- the next time the script goes back to sleep.
    local dependent_profiles = properties_to_profiles[name]
    if dependent_profiles then
        for profile, _ in pairs(dependent_profiles) do
            assert(profile.cond) -- must be a profile table
            profile.dirty = true
            have_dirty_profiles = true
        end
    end
end

local function on_idle()
    -- When events and property notifications stop, re-evaluate all dirty profiles.
    if have_dirty_profiles then
        for _, profile in ipairs(profiles) do
            if profile.dirty then
                evaluate(profile)
            end
        end
    end
    have_dirty_profiles = false
    -- Release all hooks (the point was to wait until an idle event)
    while true do
        local h = next(pending_hooks)
        if not h then
            break
        end
        pending_hooks[h] = nil
        h:cont()
    end
end

local function on_hook(h)
    h:defer()
    pending_hooks[h] = true
end

function get(name, default)
    -- Normally, we use the cached value only
    if not watched_properties[name] then
        watched_properties[name] = true
        local res, err = mp.get_property_native(name)
        -- Property has to not exist and the toplevel of property in the name must also
        -- not have an existing match in the property set for this to be considered an error.
        -- This allows things like user-data/test to still work.
        if err == "property not found" and property_set[name:match("^([^/]+)")] == nil then
            msg.error("Property '" .. name .. "' was not found.")
            return default
        end
        cached_properties[name] = res
        mp.observe_property(name, "native", on_property_change)
    end
    -- The first time the property is read we need add it to the
    -- properties_to_profiles table, which will be used to mark the profile
    -- dirty if a property referenced by it changes.
    if current_profile then
        local map = properties_to_profiles[name]
        if not map then
            map = {}
            properties_to_profiles[name] = map
        end
        map[current_profile] = true
    end
    local val = cached_properties[name]
    if val == nil then
        val = default
    end
    return val
end

local function magic_get(name)
    -- Lua identifiers can't contain "-", so in order to match with mpv
    -- property conventions, replace "_" to "-"
    name = string.gsub(name, "_", "-")
    return get(name, nil)
end

local evil_magic = {}
setmetatable(evil_magic, {
    __index = function(table, key)
        -- interpret everything as property, unless it already exists as
        -- a non-nil global value
        local v = _G[key]
        if type(v) ~= "nil" then
            return v
        end
        return magic_get(key)
    end,
})

p = {}
setmetatable(p, {
    __index = function(table, key)
        return magic_get(key)
    end,
})

local function compile_cond(name, s)
    local code, chunkname = "return " .. s, "profile " .. name .. " condition"
    local chunk, err
    if setfenv then -- lua 5.1
        chunk, err = loadstring(code, chunkname)
        if chunk then
            setfenv(chunk, evil_magic)
        end
    else -- lua 5.2
        chunk, err = load(code, chunkname, "t", evil_magic)
    end
    if not chunk then
        msg.error("Profile '" .. name .. "' condition: " .. err)
        chunk = function() return false end
    end
    return chunk
end

local function load_profiles(profiles_property)
    for _, v in ipairs(profiles_property) do
        local cond = v["profile-cond"]
        if cond and #cond > 0 then
            local profile = {
                name = v.name,
                cond = compile_cond(v.name, cond),
                properties = {},
                status = nil,
                dirty = true, -- need re-evaluate
                has_restore_opt = v["profile-restore"] and v["profile-restore"] ~= "default"
            }
            profiles[#profiles + 1] = profile
            have_dirty_profiles = true
        end
    end
end

mp.observe_property("profile-list", "native", function (_, profiles_property)
    profiles = {}
    watched_properties = {}
    cached_properties = {}
    properties_to_profiles = {}
    mp.unobserve_property(on_property_change)

    load_profiles(profiles_property)

    if #profiles < 1 and mp.get_property("load-auto-profiles") == "auto" then
        -- make it exit immediately
        _G.mp_event_loop = function() end
        return
    end

    on_idle() -- re-evaluate all profiles immediately
end)

mp.register_idle(on_idle)
for _, name in ipairs({"on_load", "on_preloaded", "on_before_start_file"}) do
    mp.add_hook(name, 50, on_hook)
end