TopBar.lua 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. local Element = require('elements/Element')
  2. ---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
  3. ---@class TopBarButton : Element
  4. local TopBarButton = class(Element)
  5. ---@param id string
  6. ---@param props TopBarButtonProps
  7. function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
  8. function TopBarButton:init(id, props)
  9. Element.init(self, id, props)
  10. self.anchor_id = 'top_bar'
  11. self.icon = props.icon
  12. self.background = props.background
  13. self.command = props.command
  14. end
  15. function TopBarButton:handle_click()
  16. mp.command(type(self.command) == 'function' and self.command() or self.command)
  17. end
  18. function TopBarButton:render()
  19. local visibility = self:get_visibility()
  20. if visibility <= 0 then return end
  21. local ass = assdraw.ass_new()
  22. -- Background on hover
  23. if self.proximity_raw == 0 then
  24. ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
  25. end
  26. cursor:zone('primary_click', self, function() self:handle_click() end)
  27. local width, height = self.bx - self.ax, self.by - self.ay
  28. local icon_size = math.min(width, height) * 0.5
  29. ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
  30. opacity = visibility, border = options.text_border * state.scale,
  31. })
  32. return ass
  33. end
  34. --[[ TopBar ]]
  35. ---@class TopBar : Element
  36. local TopBar = class(Element)
  37. function TopBar:new() return Class.new(self) --[[@as TopBar]] end
  38. function TopBar:init()
  39. Element.init(self, 'top_bar', {render_order = 4})
  40. self.size = 0
  41. self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1
  42. self.show_alt_title = false
  43. self.main_title, self.alt_title = nil, nil
  44. local function get_maximized_command()
  45. if state.platform == 'windows' then
  46. return state.border
  47. and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
  48. or 'set window-maximized no;cycle fullscreen'
  49. end
  50. return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
  51. end
  52. -- Order aligns from right to left
  53. self.buttons = {
  54. TopBarButton:new('tb_close', {
  55. icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order,
  56. }),
  57. TopBarButton:new('tb_max', {
  58. icon = 'crop_square',
  59. background = '222222',
  60. command = get_maximized_command,
  61. render_order = self.render_order,
  62. }),
  63. TopBarButton:new('tb_min', {
  64. icon = 'minimize',
  65. background = '222222',
  66. command = 'cycle window-minimized',
  67. render_order = self.render_order,
  68. }),
  69. }
  70. self:decide_titles()
  71. self:decide_enabled()
  72. self:update_dimensions()
  73. end
  74. function TopBar:destroy()
  75. for _, button in ipairs(self.buttons) do button:destroy() end
  76. Element.destroy(self)
  77. end
  78. function TopBar:decide_enabled()
  79. if options.top_bar == 'no-border' then
  80. self.enabled = not state.border or state.title_bar == false or state.fullscreen
  81. else
  82. self.enabled = options.top_bar == 'always'
  83. end
  84. self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
  85. for _, element in ipairs(self.buttons) do
  86. element.enabled = self.enabled and options.top_bar_controls
  87. end
  88. end
  89. function TopBar:decide_titles()
  90. self.alt_title = state.alt_title ~= '' and state.alt_title or nil
  91. self.main_title = state.title ~= '' and state.title or nil
  92. if (self.main_title == 'No file') then
  93. self.main_title = t('No file')
  94. end
  95. -- Fall back to alt title if main is empty
  96. if not self.main_title then
  97. self.main_title, self.alt_title = self.alt_title, nil
  98. end
  99. -- Deduplicate the main and alt titles by checking if one completely
  100. -- contains the other, and using only the longer one.
  101. if self.main_title and self.alt_title and not self.show_alt_title then
  102. local longer_title, shorter_title
  103. if #self.main_title < #self.alt_title then
  104. longer_title, shorter_title = self.alt_title, self.main_title
  105. else
  106. longer_title, shorter_title = self.main_title, self.alt_title
  107. end
  108. local escaped_shorter_title = string.gsub(shorter_title --[[@as string]], '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
  109. if string.match(longer_title --[[@as string]], escaped_shorter_title) then
  110. self.main_title, self.alt_title = longer_title, nil
  111. end
  112. end
  113. end
  114. function TopBar:update_dimensions()
  115. self.size = round(options.top_bar_size * state.scale)
  116. self.icon_size = round(self.size * 0.5)
  117. self.spacing = math.ceil(self.size * 0.25)
  118. self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
  119. self.button_width = round(self.size * 1.15)
  120. local window_border_size = Elements:v('window_border', 'size', 0)
  121. self.ay = window_border_size
  122. self.bx = display.width - window_border_size
  123. self.by = self.size + window_border_size
  124. self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
  125. self.ax = (options.top_bar_title ~= 'no' or state.has_playlist) and window_border_size or self.title_bx
  126. local button_bx = self.bx
  127. for _, element in pairs(self.buttons) do
  128. element.ax, element.bx = button_bx - self.button_width, button_bx
  129. element.ay, element.by = self.ay, self.by
  130. button_bx = button_bx - self.button_width
  131. end
  132. end
  133. function TopBar:toggle_title()
  134. if options.top_bar_alt_title_place ~= 'toggle' then return end
  135. self.show_alt_title = not self.show_alt_title
  136. end
  137. function TopBar:on_prop_title() self:decide_titles() end
  138. function TopBar:on_prop_alt_title() self:decide_titles() end
  139. function TopBar:on_prop_border()
  140. self:decide_enabled()
  141. self:update_dimensions()
  142. end
  143. function TopBar:on_prop_title_bar()
  144. self:decide_enabled()
  145. self:update_dimensions()
  146. end
  147. function TopBar:on_prop_fullscreen()
  148. self:decide_enabled()
  149. self:update_dimensions()
  150. end
  151. function TopBar:on_prop_maximized()
  152. self:decide_enabled()
  153. self:update_dimensions()
  154. end
  155. function TopBar:on_prop_has_playlist()
  156. self:decide_enabled()
  157. self:update_dimensions()
  158. end
  159. function TopBar:on_display() self:update_dimensions() end
  160. function TopBar:on_options()
  161. self:decide_enabled()
  162. self:update_dimensions()
  163. end
  164. function TopBar:render()
  165. local visibility = self:get_visibility()
  166. if visibility <= 0 then return end
  167. local ass = assdraw.ass_new()
  168. -- Window title
  169. if state.title or state.has_playlist then
  170. local bg_margin = math.floor((self.size - self.font_size) / 4)
  171. local padding = self.font_size / 2
  172. local spacing = 1
  173. local title_ax = self.ax + bg_margin
  174. local title_ay = self.ay + bg_margin
  175. local max_bx = self.title_bx - self.spacing
  176. -- Playlist position
  177. if state.has_playlist then
  178. local text = state.playlist_pos .. '' .. state.playlist_count
  179. local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
  180. .. state.playlist_count
  181. local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
  182. local rect = {
  183. ax = title_ax,
  184. ay = title_ay,
  185. bx = round(title_ax + text_width(text, opts) + padding * 2),
  186. by = self.by - bg_margin,
  187. }
  188. local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
  189. and 1 or config.opacity.playlist_position
  190. if opacity > 0 then
  191. ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
  192. color = fg, opacity = visibility * opacity, radius = state.radius,
  193. })
  194. end
  195. ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
  196. title_ax = rect.bx + bg_margin
  197. -- Click action
  198. cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
  199. end
  200. -- Skip rendering titles if there's not enough horizontal space
  201. if max_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
  202. -- Main title
  203. local main_title = self.show_alt_title and self.alt_title or self.main_title
  204. if main_title then
  205. local opts = {
  206. size = self.font_size,
  207. wrap = 2,
  208. color = bgt,
  209. opacity = visibility,
  210. border = options.text_border * state.scale,
  211. border_color = bg,
  212. clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
  213. }
  214. local bx = round(math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2))
  215. local by = self.by - bg_margin
  216. local title_rect = {ax = title_ax, ay = title_ay, bx = bx, by = by}
  217. if options.top_bar_alt_title_place == 'toggle' then
  218. cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
  219. end
  220. ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
  221. color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
  222. })
  223. ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, main_title, opts)
  224. title_ay = by + spacing
  225. end
  226. -- Alt title
  227. if self.alt_title and options.top_bar_alt_title_place == 'below' then
  228. local font_size = self.font_size * 0.9
  229. local height = font_size * 1.3
  230. local by = title_ay + height
  231. local opts = {
  232. size = font_size,
  233. wrap = 2,
  234. color = bgt,
  235. border = options.text_border * state.scale,
  236. border_color = bg,
  237. opacity = visibility,
  238. }
  239. local bx = round(math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2))
  240. opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
  241. ass:rect(title_ax, title_ay, bx, by, {
  242. color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
  243. })
  244. ass:txt(title_ax + padding, title_ay + height / 2, 4, self.alt_title, opts)
  245. title_ay = by + spacing
  246. end
  247. -- Current chapter
  248. if state.current_chapter then
  249. local padding_half = round(padding / 2)
  250. local font_size = self.font_size * 0.8
  251. local height = font_size * 1.3
  252. local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
  253. local next_chapter = state.chapters[state.current_chapter.index + 1]
  254. local chapter_end = next_chapter and next_chapter.time or state.duration or 0
  255. local remaining_time = (state.time and state.time or 0) - chapter_end
  256. local remaining_human = format_time(remaining_time, math.abs(remaining_time))
  257. local opts = {
  258. size = font_size,
  259. italic = true,
  260. wrap = 2,
  261. color = bgt,
  262. border = options.text_border * state.scale,
  263. border_color = bg,
  264. opacity = visibility * 0.8,
  265. }
  266. local remaining_width = timestamp_width(remaining_human, opts)
  267. local remaining_box_width = remaining_width + padding_half * 2
  268. -- Title
  269. local rect = {
  270. ax = title_ax,
  271. ay = title_ay,
  272. bx = round(math.min(
  273. max_bx - remaining_box_width - spacing,
  274. title_ax + text_width(text, opts) + padding * 2
  275. )),
  276. by = title_ay + height,
  277. }
  278. opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
  279. ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
  280. color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
  281. })
  282. ass:txt(rect.ax + padding, rect.ay + height / 2, 4, text, opts)
  283. -- Click action
  284. cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
  285. -- Time
  286. rect.ax = rect.bx + spacing
  287. rect.bx = rect.ax + remaining_box_width
  288. opts.clip = nil
  289. ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
  290. color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
  291. })
  292. ass:txt(rect.ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
  293. title_ay = rect.by + spacing
  294. end
  295. end
  296. self.title_by = title_ay - 1
  297. else
  298. self.title_by = self.ay
  299. end
  300. return ass
  301. end
  302. return TopBar