Controls.lua 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. local Element = require('elements/Element')
  2. local Button = require('elements/Button')
  3. local CycleButton = require('elements/CycleButton')
  4. local Speed = require('elements/Speed')
  5. -- sizing:
  6. -- static - shrink, have highest claim on available space, disappear when there's not enough of it
  7. -- dynamic - shrink to make room for static elements until they reach their ratio_min, then disappear
  8. -- gap - shrink if there's no space left
  9. -- space - expands to fill available space, shrinks as needed
  10. -- scale - `options.controls_size` scale factor.
  11. -- ratio - Width/height ratio of a static or dynamic element.
  12. -- ratio_min Min ratio for 'dynamic' sized element.
  13. ---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic' | 'gap'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table<string, boolean>}
  14. ---@class Controls : Element
  15. local Controls = class(Element)
  16. function Controls:new() return Class.new(self) --[[@as Controls]] end
  17. function Controls:init()
  18. Element.init(self, 'controls', {render_order = 6})
  19. ---@type ControlItem[] All control elements serialized from `options.controls`.
  20. self.controls = {}
  21. ---@type ControlItem[] Only controls that match current dispositions.
  22. self.layout = {}
  23. self:init_options()
  24. end
  25. function Controls:destroy()
  26. self:destroy_elements()
  27. Element.destroy(self)
  28. end
  29. function Controls:init_options()
  30. -- Serialize control elements
  31. local shorthands = {
  32. ['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
  33. menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
  34. subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
  35. audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
  36. ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
  37. video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
  38. playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
  39. chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
  40. ['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
  41. ['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
  42. ['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
  43. ['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
  44. prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
  45. next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
  46. first = 'command:first_page:script-binding uosc/first?' .. t('First'),
  47. last = 'command:last_page:script-binding uosc/last?' .. t('Last'),
  48. ['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?' .. t('Loop playlist'),
  49. ['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?' .. t('Loop file'),
  50. shuffle = 'toggle:shuffle:shuffle?' .. t('Shuffle'),
  51. fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?' .. t('Fullscreen'),
  52. }
  53. -- Parse out disposition/config pairs
  54. local items = {}
  55. local in_disposition = false
  56. local current_item = nil
  57. for c in options.controls:gmatch('.') do
  58. if not current_item then current_item = {disposition = '', config = ''} end
  59. if c == '<' and #current_item.config == 0 then
  60. in_disposition = true
  61. elseif c == '>' and #current_item.config == 0 then
  62. in_disposition = false
  63. elseif c == ',' and not in_disposition then
  64. items[#items + 1] = current_item
  65. current_item = nil
  66. else
  67. local prop = in_disposition and 'disposition' or 'config'
  68. current_item[prop] = current_item[prop] .. c
  69. end
  70. end
  71. items[#items + 1] = current_item
  72. -- Create controls
  73. self.controls = {}
  74. for i, item in ipairs(items) do
  75. local config = shorthands[item.config] and shorthands[item.config] or item.config
  76. local config_tooltip = split(config, ' *%? *')
  77. local tooltip = config_tooltip[2]
  78. config = shorthands[config_tooltip[1]]
  79. and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
  80. local config_badge = split(config, ' *# *')
  81. config = config_badge[1]
  82. local badge = config_badge[2]
  83. local parts = split(config, ' *: *')
  84. local kind, params = parts[1], itable_slice(parts, 2)
  85. -- Serialize dispositions
  86. local dispositions = {}
  87. for _, definition in ipairs(comma_split(item.disposition)) do
  88. if #definition > 0 then
  89. local value = definition:sub(1, 1) ~= '!'
  90. local name = not value and definition:sub(2) or definition
  91. local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
  92. dispositions[prop] = value
  93. end
  94. end
  95. -- Convert toggles into cycles
  96. if kind == 'toggle' then
  97. kind = 'cycle'
  98. params[#params + 1] = 'no/yes!'
  99. end
  100. -- Create a control element
  101. local control = {dispositions = dispositions, kind = kind}
  102. if kind == 'space' then
  103. control.sizing = 'space'
  104. elseif kind == 'gap' then
  105. table_assign(control, {sizing = 'gap', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
  106. elseif kind == 'command' then
  107. if #params ~= 2 then
  108. mp.error(string.format(
  109. 'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
  110. ))
  111. else
  112. local element = Button:new('control_' .. i, {
  113. render_order = self.render_order,
  114. icon = params[1],
  115. anchor_id = 'controls',
  116. on_click = function() mp.command(params[2]) end,
  117. tooltip = tooltip,
  118. count_prop = 'sub',
  119. })
  120. table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
  121. if badge then self:register_badge_updater(badge, element) end
  122. end
  123. elseif kind == 'cycle' then
  124. if #params ~= 3 then
  125. mp.error(string.format(
  126. 'cycle button needs 3 parameters, %d received: %s',
  127. #params, table.concat(params, '/')
  128. ))
  129. else
  130. local state_configs = split(params[3], ' */ *')
  131. local states = {}
  132. for _, state_config in ipairs(state_configs) do
  133. local active = false
  134. if state_config:sub(-1) == '!' then
  135. active = true
  136. state_config = state_config:sub(1, -2)
  137. end
  138. local state_params = split(state_config, ' *= *')
  139. local value, icon = state_params[1], state_params[2] or params[1]
  140. states[#states + 1] = {value = value, icon = icon, active = active}
  141. end
  142. local element = CycleButton:new('control_' .. i, {
  143. render_order = self.render_order,
  144. prop = params[2],
  145. anchor_id = 'controls',
  146. states = states,
  147. tooltip = tooltip,
  148. })
  149. table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
  150. if badge then self:register_badge_updater(badge, element) end
  151. end
  152. elseif kind == 'speed' then
  153. if not Elements.speed then
  154. local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
  155. local scale = tonumber(params[1]) or 1.3
  156. table_assign(control, {
  157. element = element, sizing = 'dynamic', scale = scale, ratio = 3.5, ratio_min = 2,
  158. })
  159. else
  160. msg.error('there can only be 1 speed slider')
  161. end
  162. else
  163. msg.error('unknown element kind "' .. kind .. '"')
  164. break
  165. end
  166. self.controls[#self.controls + 1] = control
  167. end
  168. self:reflow()
  169. end
  170. function Controls:reflow()
  171. -- Populate the layout only with items that match current disposition
  172. self.layout = {}
  173. for _, control in ipairs(self.controls) do
  174. local matches = true
  175. for prop, value in pairs(control.dispositions) do
  176. if state[prop] ~= value then
  177. matches = false
  178. break
  179. end
  180. end
  181. if control.element then control.element.enabled = matches end
  182. if matches then self.layout[#self.layout + 1] = control end
  183. end
  184. self:update_dimensions()
  185. Elements:trigger('controls_reflow')
  186. end
  187. ---@param badge string
  188. ---@param element Element An element that supports `badge` property.
  189. function Controls:register_badge_updater(badge, element)
  190. local prop_and_limit = split(badge, ' *> *')
  191. local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
  192. local observable_name, serializer, is_external_prop = prop, nil, false
  193. if itable_index_of({'sub', 'audio', 'video'}, prop) then
  194. observable_name = 'track-list'
  195. serializer = function(value)
  196. local count = 0
  197. for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
  198. return count
  199. end
  200. else
  201. local parts = split(prop, '@')
  202. -- Support both new `prop@owner` and old `@prop` syntaxes
  203. if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end
  204. serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
  205. end
  206. local function handler(_, value)
  207. local new_value = serializer(value) --[[@as nil|string|integer]]
  208. local value_number = tonumber(new_value)
  209. if value_number then new_value = value_number > limit and value_number or nil end
  210. element.badge = new_value
  211. request_render()
  212. end
  213. if is_external_prop then
  214. element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end
  215. else
  216. self:observe_mp_property(observable_name, handler)
  217. end
  218. end
  219. function Controls:get_visibility()
  220. return Elements:v('speed', 'dragging') and 1 or Elements:maybe('timeline', 'get_is_hovered')
  221. and -1 or Element.get_visibility(self)
  222. end
  223. function Controls:update_dimensions()
  224. local window_border = Elements:v('window_border', 'size', 0)
  225. local size = round(options.controls_size * state.scale)
  226. local spacing = round(options.controls_spacing * state.scale)
  227. local margin = round(options.controls_margin * state.scale)
  228. -- Disable when not enough space
  229. local available_space = display.height - window_border * 2 - Elements:v('top_bar', 'size', 0)
  230. - Elements:v('timeline', 'size', 0)
  231. self.enabled = available_space > size + 10
  232. -- Reset hide/enabled flags
  233. for c, control in ipairs(self.layout) do
  234. control.hide = false
  235. if control.element then control.element.enabled = self.enabled end
  236. end
  237. if not self.enabled then return end
  238. -- Container
  239. self.bx = display.width - window_border - margin
  240. self.by = Elements:v('timeline', 'ay', display.height - window_border) - margin
  241. self.ax, self.ay = window_border + margin, self.by - size
  242. -- Controls
  243. local available_width, statics_width = self.bx - self.ax, 0
  244. local min_content_width = statics_width
  245. local max_dynamics_width, dynamic_units, spaces, gaps = 0, 0, 0, 0
  246. -- Calculate statics_width, min_content_width, and count spaces & gaps
  247. for c, control in ipairs(self.layout) do
  248. if control.sizing == 'space' then
  249. spaces = spaces + 1
  250. elseif control.sizing == 'gap' then
  251. gaps = gaps + control.scale * control.ratio
  252. elseif control.sizing == 'static' then
  253. local width = size * control.scale * control.ratio + (c ~= #self.layout and spacing or 0)
  254. statics_width = statics_width + width
  255. min_content_width = min_content_width + width
  256. elseif control.sizing == 'dynamic' then
  257. local spacing = (c ~= #self.layout and spacing or 0)
  258. statics_width = statics_width + spacing
  259. min_content_width = min_content_width + size * control.scale * control.ratio_min + spacing
  260. max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
  261. dynamic_units = dynamic_units + control.scale * control.ratio
  262. end
  263. end
  264. -- Hide & disable elements in the middle until we fit into available width
  265. if min_content_width > available_width then
  266. local i = math.ceil(#self.layout / 2 + 0.1)
  267. for a = 0, #self.layout - 1, 1 do
  268. i = i + (a * (a % 2 == 0 and 1 or -1))
  269. local control = self.layout[i]
  270. if control.sizing ~= 'gap' and control.sizing ~= 'space' then
  271. control.hide = true
  272. if control.element then control.element.enabled = false end
  273. if control.sizing == 'static' then
  274. local width = size * control.scale * control.ratio
  275. min_content_width = min_content_width - width - spacing
  276. statics_width = statics_width - width - spacing
  277. elseif control.sizing == 'dynamic' then
  278. statics_width = statics_width - spacing
  279. min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
  280. max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
  281. dynamic_units = dynamic_units - control.scale * control.ratio
  282. end
  283. if min_content_width < available_width then break end
  284. end
  285. end
  286. end
  287. -- Lay out the elements
  288. local current_x = self.ax
  289. local width_for_dynamics = available_width - statics_width
  290. local empty_space_width = width_for_dynamics - max_dynamics_width
  291. local width_for_gaps = math.min(empty_space_width, size * gaps)
  292. local individual_space_width = spaces > 0 and ((empty_space_width - width_for_gaps) / spaces) or 0
  293. for c, control in ipairs(self.layout) do
  294. if not control.hide then
  295. local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
  296. local width, height = 0, 0
  297. if sizing == 'space' then
  298. if individual_space_width > 0 then width = individual_space_width end
  299. elseif sizing == 'gap' then
  300. if width_for_gaps > 0 then width = width_for_gaps * (ratio / gaps) end
  301. elseif sizing == 'static' then
  302. height = size * scale
  303. width = height * ratio
  304. elseif sizing == 'dynamic' then
  305. height = size * scale
  306. width = max_dynamics_width < width_for_dynamics
  307. and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
  308. end
  309. local bx = current_x + width
  310. if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
  311. current_x = element and bx + spacing or bx
  312. end
  313. end
  314. Elements:update_proximities()
  315. request_render()
  316. end
  317. function Controls:on_dispositions() self:reflow() end
  318. function Controls:on_display() self:update_dimensions() end
  319. function Controls:on_prop_border() self:update_dimensions() end
  320. function Controls:on_prop_title_bar() self:update_dimensions() end
  321. function Controls:on_prop_fullormaxed() self:update_dimensions() end
  322. function Controls:on_timeline_enabled() self:update_dimensions() end
  323. function Controls:destroy_elements()
  324. for _, control in ipairs(self.controls) do
  325. if control.element then control.element:destroy() end
  326. end
  327. end
  328. function Controls:on_options()
  329. self:destroy_elements()
  330. self:init_options()
  331. end
  332. return Controls