Timeline.lua 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. local Element = require('elements/Element')
  2. ---@class Timeline : Element
  3. local Timeline = class(Element)
  4. function Timeline:new() return Class.new(self) --[[@as Timeline]] end
  5. function Timeline:init()
  6. Element.init(self, 'timeline', {render_order = 5})
  7. ---@type false|{pause: boolean, distance: number, last: {x: number, y: number}}
  8. self.pressed = false
  9. self.obstructed = false
  10. self.size = 0
  11. self.progress_size = 0
  12. self.min_progress_size = 0 -- used for `flash-progress`
  13. self.font_size = 0
  14. self.top_border = 0
  15. self.line_width = 0
  16. self.progress_line_width = 0
  17. self.is_hovered = false
  18. self.has_thumbnail = false
  19. self:decide_progress_size()
  20. self:update_dimensions()
  21. -- Release any dragging when file gets unloaded
  22. self:register_mp_event('end-file', function() self.pressed = false end)
  23. end
  24. function Timeline:get_visibility()
  25. return math.max(Elements:maybe('controls', 'get_visibility') or 0, Element.get_visibility(self))
  26. end
  27. function Timeline:decide_enabled()
  28. local previous = self.enabled
  29. self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
  30. if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
  31. end
  32. function Timeline:get_effective_size()
  33. if Elements:v('speed', 'dragging') then return self.size end
  34. local progress_size = math.max(self.min_progress_size, self.progress_size)
  35. return progress_size + math.ceil((self.size - self.progress_size) * self:get_visibility())
  36. end
  37. function Timeline:get_is_hovered() return self.enabled and self.is_hovered end
  38. function Timeline:update_dimensions()
  39. self.size = round(options.timeline_size * state.scale)
  40. self.top_border = round(options.timeline_border * state.scale)
  41. self.line_width = round(options.timeline_line_width * state.scale)
  42. self.progress_line_width = round(options.progress_line_width * state.scale)
  43. self.font_size = math.floor(math.min((self.size + 60 * state.scale) * 0.2, self.size * 0.96) * options.font_scale)
  44. local window_border_size = Elements:v('window_border', 'size', 0)
  45. self.ax = window_border_size
  46. self.ay = display.height - window_border_size - self.size - self.top_border
  47. self.bx = display.width - window_border_size
  48. self.by = display.height - window_border_size
  49. self.width = self.bx - self.ax
  50. self.chapter_size = math.max((self.by - self.ay) / 10, 3)
  51. self.chapter_size_hover = self.chapter_size * 2
  52. -- Disable if not enough space
  53. local available_space = display.height - window_border_size * 2 - Elements:v('top_bar', 'size', 0)
  54. self.obstructed = available_space < self.size + 10
  55. self:decide_enabled()
  56. end
  57. function Timeline:decide_progress_size()
  58. local show = options.progress == 'always'
  59. or (options.progress == 'fullscreen' and state.fullormaxed)
  60. or (options.progress == 'windowed' and not state.fullormaxed)
  61. self.progress_size = show and options.progress_size or 0
  62. end
  63. function Timeline:toggle_progress()
  64. local current = self.progress_size
  65. self:tween_property('progress_size', current, current > 0 and 0 or options.progress_size)
  66. request_render()
  67. end
  68. function Timeline:flash_progress()
  69. if self.enabled and options.flash_duration > 0 then
  70. if not self._flash_progress_timer then
  71. self._flash_progress_timer = mp.add_timeout(options.flash_duration / 1000, function()
  72. self:tween_property('min_progress_size', options.progress_size, 0)
  73. end)
  74. self._flash_progress_timer:kill()
  75. end
  76. self:tween_stop()
  77. self.min_progress_size = options.progress_size
  78. request_render()
  79. self._flash_progress_timer.timeout = options.flash_duration / 1000
  80. self._flash_progress_timer:kill()
  81. self._flash_progress_timer:resume()
  82. end
  83. end
  84. function Timeline:get_time_at_x(x)
  85. local line_width = (options.timeline_style == 'line' and self.line_width - 1 or 0)
  86. local time_width = self.width - line_width - 1
  87. local fax = (time_width) * state.time / state.duration
  88. local fbx = fax + line_width
  89. -- time starts 0.5 pixels in
  90. x = x - self.ax - 0.5
  91. if x > fbx then
  92. x = x - line_width
  93. elseif x > fax then
  94. x = fax
  95. end
  96. local progress = clamp(0, x / time_width, 1)
  97. return state.duration * progress
  98. end
  99. ---@param fast? boolean
  100. function Timeline:set_from_cursor(fast)
  101. if state.time and state.duration then
  102. mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
  103. end
  104. end
  105. function Timeline:clear_thumbnail()
  106. mp.commandv('script-message-to', 'thumbfast', 'clear')
  107. self.has_thumbnail = false
  108. end
  109. function Timeline:handle_cursor_down()
  110. self.pressed = {pause = state.pause, distance = 0, last = {x = cursor.x, y = cursor.y}}
  111. mp.set_property_native('pause', true)
  112. self:set_from_cursor()
  113. end
  114. function Timeline:on_prop_duration() self:decide_enabled() end
  115. function Timeline:on_prop_time() self:decide_enabled() end
  116. function Timeline:on_prop_border() self:update_dimensions() end
  117. function Timeline:on_prop_title_bar() self:update_dimensions() end
  118. function Timeline:on_prop_fullormaxed()
  119. self:decide_progress_size()
  120. self:update_dimensions()
  121. end
  122. function Timeline:on_display() self:update_dimensions() end
  123. function Timeline:on_options()
  124. self:decide_progress_size()
  125. self:update_dimensions()
  126. end
  127. function Timeline:handle_cursor_up()
  128. if self.pressed then
  129. mp.set_property_native('pause', self.pressed.pause)
  130. self.pressed = false
  131. end
  132. end
  133. function Timeline:on_global_mouse_leave()
  134. self.pressed = false
  135. end
  136. function Timeline:on_global_mouse_move()
  137. if self.pressed then
  138. self.pressed.distance = self.pressed.distance + get_point_to_point_proximity(self.pressed.last, cursor)
  139. self.pressed.last.x, self.pressed.last.y = cursor.x, cursor.y
  140. if state.is_video and math.abs(cursor:get_velocity().x) / self.width * state.duration > 30 then
  141. self:set_from_cursor(true)
  142. else
  143. self:set_from_cursor()
  144. end
  145. end
  146. end
  147. function Timeline:handle_wheel_up() mp.commandv('seek', options.timeline_step) end
  148. function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) end
  149. function Timeline:render()
  150. if self.size == 0 then return end
  151. local size = self:get_effective_size()
  152. local visibility = self:get_visibility()
  153. self.is_hovered = false
  154. if size < 1 then
  155. if self.has_thumbnail then self:clear_thumbnail() end
  156. return
  157. end
  158. if self.proximity_raw == 0 then
  159. self.is_hovered = true
  160. end
  161. if visibility > 0 then
  162. cursor:zone('primary_down', self, function()
  163. self:handle_cursor_down()
  164. cursor:once('primary_up', function() self:handle_cursor_up() end)
  165. end)
  166. cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
  167. cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
  168. end
  169. local ass = assdraw.ass_new()
  170. local progress_size = math.max(self.min_progress_size, self.progress_size)
  171. -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches progress_size
  172. local hide_text_below = math.max(self.font_size * 0.8, progress_size * 2)
  173. local hide_text_ramp = hide_text_below / 2
  174. local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
  175. local tooltip_gap = round(2 * state.scale)
  176. local timestamp_gap = tooltip_gap
  177. local spacing = math.max(math.floor((self.size - self.font_size) / 2.5), 4)
  178. local progress = state.time / state.duration
  179. local is_line = options.timeline_style == 'line'
  180. -- Foreground & Background bar coordinates
  181. local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
  182. local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
  183. local fcy = fay + (size / 2)
  184. local line_width = 0
  185. if is_line then
  186. local minimized_fraction = 1 - math.min((size - progress_size) / ((self.size - progress_size) / 8), 1)
  187. local progress_delta = progress_size > 0 and self.progress_line_width - self.line_width or 0
  188. line_width = self.line_width + (progress_delta * minimized_fraction)
  189. fax = bax + (self.width - line_width) * progress
  190. fbx = fax + line_width
  191. line_width = line_width - 1
  192. else
  193. fax, fbx = bax, bax + self.width * progress
  194. end
  195. local foreground_size = fby - fay
  196. local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
  197. -- time starts 0.5 pixels in
  198. local time_ax = bax + 0.5
  199. local time_width = self.width - line_width - 1
  200. -- time to x: calculates x coordinate so that it never lies inside of the line
  201. local function t2x(time)
  202. local x = time_ax + time_width * time / state.duration
  203. return time <= state.time and x or x + line_width
  204. end
  205. -- Background
  206. ass:new_event()
  207. ass:pos(0, 0)
  208. ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
  209. ass:opacity(config.opacity.timeline)
  210. ass:draw_start()
  211. ass:rect_cw(bax, bay, fax, bby) --left of progress
  212. ass:rect_cw(fbx, bay, bbx, bby) --right of progress
  213. ass:rect_cw(fax, bay, fbx, fay) --above progress
  214. ass:draw_stop()
  215. -- Progress
  216. ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
  217. -- Uncached ranges
  218. local buffered_playtime = nil
  219. if state.uncached_ranges then
  220. local opts = {size = 80, anchor_y = fby}
  221. local texture_char = visibility > 0 and 'b' or 'a'
  222. local offset = opts.size / (visibility > 0 and 24 or 28)
  223. for _, range in ipairs(state.uncached_ranges) do
  224. if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then
  225. buffered_playtime = (range[1] - state.time) / (state.speed or 1)
  226. end
  227. if options.timeline_cache then
  228. local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
  229. local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
  230. opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
  231. ass:texture(ax, fay, bx, fby, texture_char, opts)
  232. opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
  233. ass:texture(ax, fay, bx, fby, texture_char, opts)
  234. end
  235. end
  236. end
  237. -- Custom ranges
  238. for _, chapter_range in ipairs(state.chapter_ranges) do
  239. local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
  240. local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
  241. or t2x(math.min(chapter_range['end'], state.duration))
  242. ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
  243. end
  244. -- Chapters
  245. local hovered_chapter = nil
  246. if (config.opacity.chapters > 0 and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)) then
  247. local diamond_radius = math.min(math.max(1, foreground_size * 0.8), self.chapter_size)
  248. local diamond_radius_hovered = diamond_radius * 2
  249. local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
  250. if diamond_radius > 0 then
  251. local function draw_chapter(time, radius)
  252. local chapter_x, chapter_y = t2x(time), fay - 1
  253. ass:new_event()
  254. ass:append(string.format(
  255. '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
  256. diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
  257. ))
  258. ass:draw_start()
  259. ass:move_to(chapter_x - radius, chapter_y)
  260. ass:line_to(chapter_x, chapter_y - radius)
  261. ass:line_to(chapter_x + radius, chapter_y)
  262. ass:line_to(chapter_x, chapter_y + radius)
  263. ass:draw_stop()
  264. end
  265. if #state.chapters > 0 then
  266. -- Find hovered chapter indicator
  267. local closest_delta = math.huge
  268. if self.proximity_raw < diamond_radius_hovered then
  269. for i, chapter in ipairs(state.chapters) do
  270. local chapter_x, chapter_y = t2x(chapter.time), fay - 1
  271. local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
  272. if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
  273. hovered_chapter, closest_delta = chapter, cursor_chapter_delta
  274. self.is_hovered = true
  275. end
  276. end
  277. end
  278. for i, chapter in ipairs(state.chapters) do
  279. if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
  280. local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
  281. if visibility > 0 then
  282. cursor:zone('primary_down', circle, function()
  283. mp.commandv('seek', chapter.time, 'absolute+exact')
  284. end)
  285. end
  286. end
  287. -- Render hovered chapter above others
  288. if hovered_chapter then
  289. draw_chapter(hovered_chapter.time, diamond_radius_hovered)
  290. timestamp_gap = tooltip_gap + round(diamond_radius_hovered)
  291. else
  292. timestamp_gap = tooltip_gap + round(diamond_radius)
  293. end
  294. end
  295. -- A-B loop indicators
  296. local has_a, has_b = state.ab_loop_a and state.ab_loop_a >= 0, state.ab_loop_b and state.ab_loop_b > 0
  297. local ab_radius = round(math.min(math.max(8, foreground_size * 0.25), foreground_size))
  298. ---@param time number
  299. ---@param kind 'a'|'b'
  300. local function draw_ab_indicator(time, kind)
  301. local x = t2x(time)
  302. ass:new_event()
  303. ass:append(string.format(
  304. '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
  305. diamond_border, fg, bg, bg, opacity_to_alpha(config.opacity.chapters)
  306. ))
  307. ass:draw_start()
  308. ass:move_to(x, fby - ab_radius)
  309. if kind == 'b' then ass:line_to(x + 3, fby - ab_radius) end
  310. ass:line_to(x + (kind == 'a' and 0 or ab_radius), fby)
  311. ass:line_to(x - (kind == 'b' and 0 or ab_radius), fby)
  312. if kind == 'a' then ass:line_to(x - 3, fby - ab_radius) end
  313. ass:draw_stop()
  314. end
  315. if has_a then draw_ab_indicator(state.ab_loop_a, 'a') end
  316. if has_b then draw_ab_indicator(state.ab_loop_b, 'b') end
  317. end
  318. end
  319. local function draw_timeline_timestamp(x, y, align, timestamp, opts)
  320. opts.color, opts.border_color = fgt, fg
  321. opts.clip = '\\clip(' .. foreground_coordinates .. ')'
  322. local func = options.time_precision > 0 and ass.timestamp or ass.txt
  323. func(ass, x, y, align, timestamp, opts)
  324. opts.color, opts.border_color = bgt, bg
  325. opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
  326. func(ass, x, y, align, timestamp, opts)
  327. end
  328. -- Time values
  329. if text_opacity > 0 then
  330. local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
  331. -- Upcoming cache time
  332. if buffered_playtime and options.buffered_time_threshold > 0
  333. and buffered_playtime < options.buffered_time_threshold then
  334. local margin = 5 * state.scale
  335. local x, align = fbx + margin, 4
  336. local cache_opts = {
  337. size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
  338. }
  339. local human = round(math.max(buffered_playtime, 0)) .. 's'
  340. local width = text_width(human, cache_opts)
  341. local time_width = timestamp_width(state.time_human, time_opts)
  342. local time_width_end = timestamp_width(state.destination_time_human, time_opts)
  343. local min_x, max_x = bax + spacing + margin + time_width, bbx - spacing - margin - time_width_end
  344. if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
  345. draw_timeline_timestamp(x, fcy, align, human, cache_opts)
  346. end
  347. -- Elapsed time
  348. if state.time_human then
  349. draw_timeline_timestamp(bax + spacing, fcy, 4, state.time_human, time_opts)
  350. end
  351. -- End time
  352. if state.destination_time_human then
  353. draw_timeline_timestamp(bbx - spacing, fcy, 6, state.destination_time_human, time_opts)
  354. end
  355. end
  356. -- Hovered time and chapter
  357. local rendered_thumbnail = false
  358. if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
  359. local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x
  360. local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x)
  361. -- Cursor line
  362. -- 0.5 to switch when the pixel is half filled in
  363. local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg
  364. local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby
  365. ass:rect(ax, ay, bx, by, {color = color, opacity = 0.33})
  366. local tooltip_anchor = {ax = ax, ay = ay - self.top_border, bx = bx, by = by}
  367. -- Timestamp
  368. local opts = {
  369. size = self.font_size, offset = timestamp_gap, margin = tooltip_gap, timestamp = options.time_precision > 0,
  370. }
  371. local hovered_time_human = format_time(hovered_seconds, state.duration)
  372. opts.width_overwrite = timestamp_width(hovered_time_human, opts)
  373. tooltip_anchor = ass:tooltip(tooltip_anchor, hovered_time_human, opts)
  374. -- Thumbnail
  375. if not thumbnail.disabled
  376. and (not self.pressed or self.pressed.distance < 5)
  377. and thumbnail.width ~= 0
  378. and thumbnail.height ~= 0
  379. then
  380. local border = math.ceil(math.max(2, state.radius / 2) * state.scale)
  381. local thumb_x_margin, thumb_y_margin = border + tooltip_gap + bax, border + tooltip_gap
  382. local thumb_width, thumb_height = thumbnail.width, thumbnail.height
  383. local thumb_x = round(clamp(
  384. thumb_x_margin,
  385. cursor_x - thumb_width / 2,
  386. display.width - thumb_width - thumb_x_margin
  387. ))
  388. local thumb_y = round(tooltip_anchor.ay - thumb_y_margin - thumb_height)
  389. local ax, ay = (thumb_x - border), (thumb_y - border)
  390. local bx, by = (thumb_x + thumb_width + border), (thumb_y + thumb_height + border)
  391. ass:rect(ax, ay, bx, by, {
  392. color = bg,
  393. border = 1,
  394. opacity = {main = config.opacity.thumbnail, border = 0.08 * config.opacity.thumbnail},
  395. border_color = fg,
  396. radius = state.radius,
  397. })
  398. mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
  399. self.has_thumbnail, rendered_thumbnail = true, true
  400. tooltip_anchor.ay = ay
  401. end
  402. -- Chapter title
  403. if #state.chapters > 0 then
  404. local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end,
  405. #state.chapters, 1)
  406. if chapter and not chapter.is_end_only then
  407. ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
  408. size = self.font_size,
  409. offset = tooltip_gap,
  410. responsive = false,
  411. bold = true,
  412. width_overwrite = chapter.title_wrapped_width * self.font_size,
  413. lines = chapter.title_lines,
  414. margin = tooltip_gap,
  415. })
  416. end
  417. end
  418. end
  419. -- Clear thumbnail
  420. if not rendered_thumbnail and self.has_thumbnail then self:clear_thumbnail() end
  421. return ass
  422. end
  423. return Timeline