-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpip.lua
418 lines (381 loc) · 14.2 KB
/
pip.lua
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
-- mpv Picture-in-Picture on Windows
-- https://github.com/verygoodlee/mpv-pip
local ffi_ok, ffi = pcall(require, 'ffi')
if not ffi_ok then return end -- mpv builds without luajit
local bit = require('bit')
local msg = require('mp.msg')
local utils = require('mp.utils')
local options = require('mp.options')
local user_opts = {
-- key for PiP on/off
key = 'c',
-- PiP window size, the syntax is the same as https://mpv.io/manual/stable/#options-autofit
-- e.g. 25%x25% 400x300
autofit = '25%x25%',
-- PiP window alignment, default right-bottom corner
-- <left|center|right>
align_x = 'right',
-- <top|center|bottom>
align_y = 'bottom',
-- add thin-line border to PiP window, works only on Windows11
thin_border = false,
}
function validate_user_opts()
if not (user_opts.autofit:match('^%d+%%?x%d+%%?$') or user_opts.autofit:match('^%d+%%?$')) then
msg.warn('autofit option is invalid')
user_opts.autofit = '25%x25%'
end
if not (user_opts.align_x == 'left' or user_opts.align_x == 'center' or user_opts.align_x == 'right') then
msg.warn('align_x option is invalid')
user_opts.align_x = 'right'
end
if not (user_opts.align_y == 'top' or user_opts.align_y == 'center' or user_opts.align_y == 'bottom') then
msg.warn('align_y option is invalid')
user_opts.align_y = 'bottom'
end
user_opts.thin_border = user_opts.thin_border and mp.get_property_native('title-bar') ~= nil
resize_pip_window()
end
options.read_options(user_opts, _, validate_user_opts)
---------- win32api start ----------
ffi.cdef[[
typedef void* HWND;
typedef void* HMONITOR;
typedef int BOOL;
typedef unsigned int DWORD;
typedef unsigned int LPDWORD[1];
typedef int LPARAM;
typedef long LONG;
typedef long LONG_PTR;
typedef unsigned int UINT;
typedef void* PVOID;
typedef BOOL (*WNDENUMPROC)(HWND, LPARAM);
typedef struct tagRECT {
LONG left;
LONG top;
LONG right;
LONG bottom;
} RECT, *PRECT, *NPRECT, *LPRECT;
typedef struct tagMONITORINFO {
DWORD cbSize;
RECT rcMonitor;
RECT rcWork;
DWORD dwFlags;
} MONITORINFO, *LPMONITORINFO;
HWND GetForegroundWindow();
BOOL EnumWindows(WNDENUMPROC lpEnumFunc, LPARAM lParam);
DWORD GetWindowThreadProcessId(HWND hwnd, LPDWORD lpdwProcessId);
HMONITOR MonitorFromWindow(HWND hwnd, DWORD dwFlags);
BOOL GetMonitorInfoA(HMONITOR hMonitor, LPMONITORINFO lpmi);
BOOL SystemParametersInfoA(UINT uiAction, UINT uiParam, PVOID pvParam, UINT fWinIni);
BOOL ShowWindow(HWND hwnd, int nCmdShow);
BOOL MoveWindow(HWND hwnd, int X, int Y, int nWidth, int nHeight, BOOL bRepaint);
LONG_PTR GetWindowLongPtrW(HWND hwnd, int nIndex);
LONG_PTR SetWindowLongPtrW(HWND hwnd, int nIndex, LONG_PTR dwNewLong);
BOOL AdjustWindowRect(LPRECT lpRect, DWORD dwStyle, BOOL bMenu);
]]
local user32 = ffi.load('user32')
local mpv_hwnd = nil
function init()
if mpv_hwnd then return true end
-- find mpv window
local foreground_hwnd = user32.GetForegroundWindow()
if is_mpv_window(foreground_hwnd) then
mpv_hwnd = foreground_hwnd
else
user32.EnumWindows(function(each_hwnd, _)
if is_mpv_window(each_hwnd) then
mpv_hwnd = each_hwnd
return false
end
return true
end, 0)
end
if not mpv_hwnd then msg.warn('mpv window not found') end
return mpv_hwnd ~= nil
end
function is_mpv_window(hwnd)
if not hwnd then return false end
local lpdwProcessId = ffi.new('LPDWORD')
user32.GetWindowThreadProcessId(hwnd, lpdwProcessId)
return lpdwProcessId[0] == utils.getpid()
end
-- get work area of display monitor, is the portion not obscured by the system taskbar
function get_work_area()
local work_area = {left = 0, top = 0, right = 0, bottom = 0}
if not init() then return work_area end
-- get display monitor that has the largest area of intersection with mpv window
local MONITOR_DEFAULTTONEAREST = 0x00000002
local hmonitor = user32.MonitorFromWindow(mpv_hwnd, MONITOR_DEFAULTTONEAREST)
if hmonitor ~= nil then
local monitor_info = ffi.new('MONITORINFO', {cbSize = ffi.sizeof('MONITORINFO')})
if user32.GetMonitorInfoA(hmonitor, monitor_info) ~= 0 then
work_area.left = monitor_info.rcWork.left
work_area.top = monitor_info.rcWork.top
work_area.right = monitor_info.rcWork.right
work_area.bottom = monitor_info.rcWork.bottom
return work_area
end
end
-- fallback: primary display monitor
local rect = ffi.new('RECT')
local SPI_GETWORKAREA = 0x0030
if user32.SystemParametersInfoA(SPI_GETWORKAREA, 0, rect, 0) ~= 0 then
work_area.left = rect.left
work_area.top = rect.top
work_area.right = rect.right
work_area.bottom = rect.bottom
return work_area
end
msg.warn('failed to get work area of display monitor')
return work_area
end
function move_window(w, h, align_x, align_y, taskbar)
if not init() then return false end
if w <= 0 or h <= 0 then
msg.warn('window size error')
return false
end
local invisible_borders_size = {left = 0, right = 0, top = 0, bottom = 0}
if user_opts.thin_border then
local thin_border_size = 1
local rect = ffi.new('RECT')
rect.left, rect.top, rect.right, rect.bottom = 0, 0, w, h
local GWL_STYLE = -16
user32.AdjustWindowRect(rect, user32.GetWindowLongPtrW(mpv_hwnd, GWL_STYLE), 0)
local invisible_title_height = -rect.top - thin_border_size
local w2, h2 = rect.right - rect.left, rect.bottom - rect.top - invisible_title_height
invisible_borders_size.left = -rect.left - thin_border_size
invisible_borders_size.right = w2 - w - invisible_borders_size.left - 2 * thin_border_size
invisible_borders_size.bottom = h2 - h - 2 * thin_border_size
w, h = w2, h2
end
local x, y
local work_area = get_work_area()
if align_x == 'left' then
x = work_area.left - invisible_borders_size.left
elseif align_x == 'right' then
x = work_area.right - w + invisible_borders_size.right
else
x = (work_area.left+work_area.right)/2 - (w+invisible_borders_size.left-invisible_borders_size.right)/2
end
if align_y == 'top' then
y = work_area.top - invisible_borders_size.top
elseif align_y == 'bottom' then
y = work_area.bottom - h + invisible_borders_size.bottom
else
y = (work_area.top+work_area.bottom)/2 - (h+invisible_borders_size.top-invisible_borders_size.bottom)/2
end
show_window(false)
local success = user32.MoveWindow(mpv_hwnd, x, y, w, h, 0) ~= 0
if success then show_in_taskbar(taskbar) end
show_window(true)
return success
end
function show_window(show)
if not init() then return end
local SW_HIDE, SW_SHOW = 0, 5
user32.ShowWindow(mpv_hwnd, show and SW_SHOW or SW_HIDE)
end
function show_in_taskbar(show)
if not init() then return end
local GWL_EXSTYLE, WS_EX_TOOLWINDOW = -20, 0x00000080
local exstyle = user32.GetWindowLongPtrW(mpv_hwnd, GWL_EXSTYLE)
exstyle = bit.band(exstyle, bit.bnot(WS_EX_TOOLWINDOW))
if not show then exstyle = bit.bor(exstyle, WS_EX_TOOLWINDOW) end
user32.SetWindowLongPtrW(mpv_hwnd, GWL_EXSTYLE, exstyle)
end
---------- win32api end ----------
---------- helper functions start ----------
function round(num)
if num >= 0 then return math.floor(num + 0.5) end
return math.ceil(num - 0.5)
end
function is_empty(o)
if o == nil or o == '' then return true end
if type(o) == 'table' then return next(o) == nil end
return false
end
local video_out_params = nil
function get_video_out_size()
local w = video_out_params and video_out_params['dw'] or 960
local h = video_out_params and video_out_params['dh'] or 540
local rotate = video_out_params and video_out_params['rotate'] or 0
if rotate % 180 == 90 then return h, w, h/w end
return w, h, w/h
end
function parse_autofit(atf, larger)
local w, h = 0, 0
local work_area = get_work_area()
if atf:match('^%d+%%?x%d+%%?$') then -- WxH
w, h = atf:match('^(%d+)%%?x(%d+)%%?$')
w, h = tonumber(w), tonumber(h)
local w_percent, h_percent = atf:match('^%d+(%%?)x%d+(%%?)$')
if not is_empty(w_percent) then
w = round((work_area.right - work_area.left) * w / 100)
end
if not is_empty(h_percent) then
h = round((work_area.bottom - work_area.top) * h / 100)
end
elseif atf:match('^%d+%%?$') then -- W
w = tonumber(atf:match('^(%d+)%%?$'))
local w_percent = atf:match('^%d+(%%?)$')
if not is_empty(w_percent) then
w = round((work_area.right - work_area.left) * w / 100)
end
else
msg.warn('autofit value is invalid: ' .. atf)
end
if w > 0 then
-- fit to the video aspect ratio
if h <= 0 then h = larger and 100000000 or 1 end
local _, _, aspect = get_video_out_size()
if aspect > w / h then
if larger then h = round(w / aspect)
else w = round(h * aspect)
end
elseif aspect < w / h then
if larger then w = round(h * aspect)
else h = round(w / aspect)
end
end
end
return w, h
end
function get_pip_window_size()
return parse_autofit(user_opts.autofit, true)
end
function get_normal_window_size()
local w_max, h_max = 100000000, 100000000
local atf_larger = mp.get_property('autofit-larger')
if not is_empty(atf_larger) then
w_max, h_max = parse_autofit(atf_larger, true)
end
local w_min, h_min= 0, 0
local atf_smaller = mp.get_property('autofit-smaller')
if not is_empty(atf_smaller) then
w_min, h_min = parse_autofit(atf_smaller, false)
if w_min > w_max then w_min, h_min = w_max, h_max end
end
local w, h = get_video_out_size()
local atf = mp.get_property('autofit')
if not is_empty(atf) then
w, h = parse_autofit(atf, true)
end
if w >= w_max then return w_max, h_max
elseif w >= w_min then return w, h
else return w_min, h_min
end
end
---------- helper functions end ----------
local pip_on = false
-- these properties will be set when pip is on
local pip_props = {
['fullscreen'] = false,
['window-minimized'] = false,
['window-maximized'] = false,
['auto-window-resize'] = false,
['keepaspect-window'] = true,
['ontop'] = true,
[user_opts.thin_border and 'title-bar' or 'border'] = false,
['border'] = user_opts.thin_border,
}
-- original properties before pip is on, pip window back to normal window will restore these properties
local original_props = {
['auto-window-resize'] = true,
['keepaspect-window'] = true,
['ontop'] = false,
[user_opts.thin_border and 'title-bar' or 'border'] = true,
['border'] = true,
}
function set_pip_props()
-- save original props before set
for name, _ in pairs(original_props) do
original_props[name] = mp.get_property_native(name)
end
for name, val in pairs(pip_props) do
mp.set_property_native(name, val)
end
end
function set_original_props()
for name, val in pairs(original_props) do
mp.set_property_native(name, val)
end
end
local turn_on_timer = mp.add_timeout(0.05, function()
video_out_params = mp.get_property_native('video-out-params')
local w, h = get_pip_window_size()
local success = move_window(w, h, user_opts.align_x, user_opts.align_y, false)
if not success then
unobserve_props()
set_original_props()
return
end
msg.info(string.format('Picture-in-Picture: on, Size: %dx%d', w, h))
pip_on = true
mp.set_property_bool('user-data/pip/on', true)
end, true)
-- pip on
function on()
if pip_on or not init() or turn_on_timer:is_enabled() then return end
set_pip_props()
observe_props()
turn_on_timer:resume()
end
-- pip off
function off()
if not pip_on or not init() then return end
local w, h = get_normal_window_size()
local success = move_window(w, h, 'center', 'center', true)
if not success then return end
msg.info(string.format('Picture-in-Picture: off, Size: %dx%d', w, h))
unobserve_props()
set_original_props()
pip_on = false
mp.set_property_bool('user-data/pip/on', false)
end
-- pip toggle
function toggle()
if pip_on then off() else on() end
end
function observe_props()
for name, _ in pairs(pip_props) do
mp.observe_property(name, 'native', on_pip_prop_change)
end
mp.register_event('video-reconfig', on_video_reconfig)
end
function unobserve_props()
mp.unobserve_property(on_pip_prop_change)
mp.unregister_event(on_video_reconfig)
end
function on_video_reconfig()
if not pip_on then return end
local w0, h0 = get_pip_window_size()
video_out_params = mp.get_property_native('video-out-params')
local w1, h1 = get_pip_window_size()
local resized = false
if not (w0 == w1 and h0 == h1) then resized = resize_pip_window(w1, h1) end
if not resized then show_in_taskbar(false) end
end
function on_pip_prop_change(name, val)
if not pip_on then return end
if val == pip_props[name] then return end
mp.set_property_native(name, pip_props[name])
if name == 'fullscreen' or name == 'window-maximized' or
name == 'border' or name == 'title-bar' then
mp.add_timeout(0.1, function() show_in_taskbar(false) end)
end
end
function resize_pip_window(w, h)
if not pip_on then return false end
if not w or not h then w, h = get_pip_window_size() end
local resized = move_window(w, h, user_opts.align_x, user_opts.align_y, false)
if resized then msg.info(string.format('Resize: %dx%d', w, h)) end
return resized
end
-- IMPORTANT: reset mpv_hwnd on VO change
mp.observe_property('current-vo', 'string', function(_, val) if val then mpv_hwnd = nil end end)
validate_user_opts()
mp.add_key_binding(user_opts.key, 'toggle', toggle)
mp.register_script_message('on', on)
mp.register_script_message('off', off)