summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts/uosc_shared/elements/Timeline.lua
blob: 5ef798fb3999c4fcd1a42888da208e31d696e542 (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
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
local Element = require('uosc_shared/elements/Element')

---@class Timeline : Element
local Timeline = class(Element)

function Timeline:new() return Class.new(self) --[[@as Timeline]] end
function Timeline:init()
	Element.init(self, 'timeline')
	self.pressed = false
	self.obstructed = false
	self.size_max = 0
	self.size_min = 0
	self.size_min_override = options.timeline_start_hidden and 0 or nil
	self.font_size = 0
	self.top_border = options.timeline_border
	self.hovered_chapter = nil

	-- Release any dragging when file gets unloaded
	mp.register_event('end-file', function() self.pressed = false end)
end

function Timeline:get_visibility()
	return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self))
		or Element.get_visibility(self)
end

function Timeline:decide_enabled()
	local previous = self.enabled
	self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
	if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
end

function Timeline:get_effective_size_min()
	return self.size_min_override or self.size_min
end

function Timeline:get_effective_size()
	if Elements.speed and Elements.speed.dragging then return self.size_max end
	local size_min = self:get_effective_size_min()
	return size_min + math.ceil((self.size_max - size_min) * self:get_visibility())
end

function Timeline:get_effective_line_width()
	return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width
end

function Timeline:get_is_hovered() return self.enabled and (self.proximity_raw == 0 or self.hovered_chapter ~= nil) end

function Timeline:update_dimensions()
	if state.fullormaxed then
		self.size_min = options.timeline_size_min_fullscreen
		self.size_max = options.timeline_size_max_fullscreen
	else
		self.size_min = options.timeline_size_min
		self.size_max = options.timeline_size_max
	end
	self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale)
	self.ax = Elements.window_border.size
	self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border
	self.bx = display.width - Elements.window_border.size
	self.by = display.height - Elements.window_border.size
	self.width = self.bx - self.ax
	self.chapter_size = math.max((self.by - self.ay) / 10, 3)
	self.chapter_size_hover = self.chapter_size * 2

	-- Disable if not enough space
	local available_space = display.height - Elements.window_border.size * 2
	if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end
	self.obstructed = available_space < self.size_max + 10
	self:decide_enabled()
end

function Timeline:get_time_at_x(x)
	local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() - 1 or 0)
	local time_width = self.width - line_width - 1
	local fax = (time_width) * state.time / state.duration
	local fbx = fax + line_width
	-- time starts 0.5 pixels in
	x = x - self.ax - 0.5
	if x > fbx then x = x - line_width
	elseif x > fax then x = fax end
	local progress = clamp(0, x / time_width, 1)
	return state.duration * progress
end

---@param fast? boolean
function Timeline:set_from_cursor(fast)
	if state.time and state.duration then
		mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
	end
end
function Timeline:clear_thumbnail() mp.commandv('script-message-to', 'thumbfast', 'clear') end

function Timeline:determine_chapter_click_handler()
	if self.hovered_chapter then
		if not self.on_global_mbtn_left_down then
			self.on_global_mbtn_left_down = function()
				if self.hovered_chapter then mp.commandv('seek', self.hovered_chapter.time, 'absolute+exact') end
			end
		end
	else
		if self.on_global_mbtn_left_down then
			self.on_global_mbtn_left_down = nil
			if self.proximity_raw ~= 0 then self:clear_thumbnail() end
		end
	end
end

function Timeline:on_mbtn_left_down()
	-- `self.on_global_mbtn_left_down` has precedent
	if self.on_global_mbtn_left_down then return end

	self.pressed = true
	self.pressed_pause = state.pause
	mp.set_property_native('pause', true)
	self:set_from_cursor()
end
function Timeline:on_prop_duration() self:decide_enabled() end
function Timeline:on_prop_time() self:decide_enabled() end
function Timeline:on_prop_border() self:update_dimensions() end
function Timeline:on_prop_fullormaxed() self:update_dimensions() end
function Timeline:on_display() self:update_dimensions() end
function Timeline:on_mouse_leave()
	if not self.hovered_chapter then self:clear_thumbnail() end
end
function Timeline:on_global_mbtn_left_up()
	if self.pressed then
		mp.set_property_native('pause', self.pressed_pause)
		self.pressed = false
	end
	self:clear_thumbnail()
end
function Timeline:on_global_mouse_leave()
	self.pressed = false
	self:clear_thumbnail()
end

Timeline.seek_timer = mp.add_timeout(0.05, function() Elements.timeline:set_from_cursor() end)
Timeline.seek_timer:kill()
function Timeline:on_global_mouse_move()
	if self.pressed then
		if self.width / state.duration < 10 then
			self:set_from_cursor(true)
			self.seek_timer:kill()
			self.seek_timer:resume()
		else self:set_from_cursor() end
	end
	self:determine_chapter_click_handler()
end
function Timeline:on_wheel_up() mp.commandv('seek', options.timeline_step) end
function Timeline:on_wheel_down() mp.commandv('seek', -options.timeline_step) end

function Timeline:render()
	if self.size_max == 0 then return end

	local size_min = self:get_effective_size_min()
	local size = self:get_effective_size()
	local visibility = self:get_visibility()

	if size < 1 then return end

	local ass = assdraw.ass_new()

	-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
	local hide_text_below = math.max(self.font_size * 0.8, size_min * 2)
	local hide_text_ramp = hide_text_below / 2
	local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp

	local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4)
	local progress = state.time / state.duration
	local is_line = options.timeline_style == 'line'

	-- Foreground & Background bar coordinates
	local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
	local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
	local fcy = fay + (size / 2)

	local line_width = 0

	if is_line then
		local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1)
		local line_width_max = self:get_effective_line_width()
		local max_min_width_delta = size_min > 0
			and line_width_max - line_width_max * options.timeline_line_width_minimized_scale
			or 0
		line_width = line_width_max - (max_min_width_delta * minimized_fraction)
		fax = bax + (self.width - line_width) * progress
		fbx = fax + line_width
		line_width = line_width - 1
	else
		fax, fbx = bax, bax + self.width * progress
	end

	local foreground_size = fby - fay
	local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping

	-- time starts 0.5 pixels in
	local time_ax = bax + 0.5
	local time_width = self.width - line_width - 1

	-- time to x: calculates x coordinate so that it never lies inside of the line
	local function t2x(time)
		local x = time_ax + time_width * time / state.duration
		return time <= state.time and x or x + line_width
	end

	-- Background
	ass:new_event()
	ass:pos(0, 0)
	ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
	ass:opacity(options.timeline_opacity)
	ass:draw_start()
	ass:rect_cw(bax, bay, fax, bby) --left of progress
	ass:rect_cw(fbx, bay, bbx, bby) --right of progress
	ass:rect_cw(fax, bay, fbx, fay) --above progress
	ass:draw_stop()

	-- Progress
	ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity})

	-- Uncached ranges
	local buffered_time = nil
	if state.uncached_ranges then
		local opts = {size = 80, anchor_y = fby}
		local texture_char = visibility > 0 and 'b' or 'a'
		local offset = opts.size / (visibility > 0 and 24 or 28)
		for _, range in ipairs(state.uncached_ranges) do
			if not buffered_time and (range[1] > state.time or range[2] > state.time) then
				buffered_time = range[1] - state.time
			end
			if options.timeline_cache then
				local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
				local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
				opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
				ass:texture(ax, fay, bx, fby, texture_char, opts)
				opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
				ass:texture(ax, fay, bx, fby, texture_char, opts)
			end
		end
	end

	-- Custom ranges
	for _, chapter_range in ipairs(state.chapter_ranges) do
		local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
		local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
			or t2x(math.min(chapter_range['end'], state.duration))
		ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
	end

	-- Chapters
	self.hovered_chapter = nil
	if (options.timeline_chapters_opacity > 0
		and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)
		) then
		local diamond_radius = foreground_size < 3 and foreground_size or self.chapter_size
		local diamond_radius_hovered = diamond_radius * 2
		local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1

		if diamond_radius > 0 then
			local function draw_chapter(time, radius)
				local chapter_x, chapter_y = t2x(time), fay - 1
				ass:new_event()
				ass:append(string.format(
					'{\\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&}',
					diamond_border, fg, bg, bg, opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity)
				))
				ass:draw_start()
				ass:move_to(chapter_x - radius, chapter_y)
				ass:line_to(chapter_x, chapter_y - radius)
				ass:line_to(chapter_x + radius, chapter_y)
				ass:line_to(chapter_x, chapter_y + radius)
				ass:draw_stop()
			end

			if #state.chapters > 0 then
				-- Find hovered chapter indicator
				local hovered_chapter, closest_delta = nil, infinity

				if self.proximity_raw < diamond_radius_hovered then
					for i, chapter in ipairs(state.chapters) do
						local chapter_x, chapter_y = t2x(chapter.time), fay - 1
						local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
						if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
							hovered_chapter, closest_delta = chapter, cursor_chapter_delta
						end
					end
				end

				for i, chapter in ipairs(state.chapters) do
					if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
				end

				-- Render hovered chapter above others
				if hovered_chapter then
					draw_chapter(hovered_chapter.time, diamond_radius_hovered)
					self.hovered_chapter = hovered_chapter
					self:determine_chapter_click_handler()
				end
			end

			if state.ab_loop_a and state.ab_loop_a > 0 then draw_chapter(state.ab_loop_a) end
			if state.ab_loop_b and state.ab_loop_b > 0 then draw_chapter(state.ab_loop_b) end
		end
	end

	local function draw_timeline_text(x, y, align, text, opts)
		opts.color, opts.border_color = fgt, fg
		opts.clip = '\\clip(' .. foreground_coordinates .. ')'
		ass:txt(x, y, align, text, opts)
		opts.color, opts.border_color = bgt, bg
		opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
		ass:txt(x, y, align, text, opts)
	end

	-- Time values
	if text_opacity > 0 then
		local time_opts = {size = self.font_size, opacity = text_opacity, border = 2}
		-- Upcoming cache time
		if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then
			local x, align = fbx + 5, 4
			local cache_opts = {size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = 1}
			local human = round(math.max(buffered_time, 0)) .. 's'
			local width = text_width(human, cache_opts)
			local time_width = text_width('00:00:00', time_opts)
			local min_x, max_x = bax + spacing + 5 + time_width, bbx - spacing - 5 - time_width
			if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
			draw_timeline_text(x, fcy, align, human, cache_opts)
		end

		-- Elapsed time
		if state.time_human then
			draw_timeline_text(bax + spacing, fcy, 4, state.time_human, time_opts)
		end

		-- End time
		if state.duration_or_remaining_time_human then
			draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, time_opts)
		end
	end

	-- Hovered time and chapter
	if (self.proximity_raw == 0 or self.pressed or self.hovered_chapter) and
		not (Elements.speed and Elements.speed.dragging) then
		local cursor_x = self.hovered_chapter and t2x(self.hovered_chapter.time) or cursor.x
		local hovered_seconds = self.hovered_chapter and self.hovered_chapter.time or self:get_time_at_x(cursor.x)

		-- Cursor line
		-- 0.5 to switch when the pixel is half filled in
		local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg
		local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby
		ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2})
		local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by}

		-- Timestamp
		local offset = #state.chapters > 0 and 10 or 4
		local opts = {size = self.font_size, offset = offset}
		opts.width_overwrite = text_width('00:00:00', opts)
		ass:tooltip(tooltip_anchor, format_time(hovered_seconds), opts)
		tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - offset

		-- Thumbnail
		if not thumbnail.disabled and thumbnail.width ~= 0 and thumbnail.height ~= 0 then
			local scale_x, scale_y = display.scale_x, display.scale_y
			local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y)
			local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y
			local thumb_width, thumb_height = thumbnail.width, thumbnail.height
			local thumb_x = round(clamp(
				thumb_x_margin, cursor_x * scale_x - thumb_width / 2,
				display.width * scale_x - thumb_width - thumb_x_margin
			))
			local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height)
			local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y
			local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y
			ass:rect(ax, ay, bx, by, {color = bg, border = 1, border_color = fg, border_opacity = 0.08, radius = 2})
			mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
			tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay
		end

		-- Chapter title
		if #state.chapters > 0 then
			local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true)
			if chapter and not chapter.is_end_only then
				ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
					size = self.font_size, offset = 10, responsive = false, bold = true,
					width_overwrite = chapter.title_wrapped_width * self.font_size,
				})
			end
		end
	end

	return ass
end

return Timeline